diff --git a/.claude/skills/upgrade-packages/SKILL.md b/.claude/skills/upgrade-packages/SKILL.md index 11826ed..ef8092c 100644 --- a/.claude/skills/upgrade-packages/SKILL.md +++ b/.claude/skills/upgrade-packages/SKILL.md @@ -7,254 +7,97 @@ argument-hint: "[--check-only] [--major] [package-name]" # Upgrade Packages -Upgrade all project dependencies to their latest compatible (or latest major, if `--major`) versions. +Upgrade all project dependencies to their latest compatible versions (or latest major with `--major`), then drive known vulnerabilities to zero. ## 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). +- `--check-only` — list outdated packages and stop after Step 2. +- `--major` — allow major-version bumps; otherwise stay within semver-compatible ranges. +- Any other argument is treated as a single package name to upgrade. -## Step 1 — Detect language and package manager +## Ecosystem command reference -Inspect the repo root and subdirectories for manifest files. Identify ALL that apply: +Look up the detected ecosystem(s) in this table and use the matching commands in each step below. Process every ecosystem present. -| Manifest file | Language | Package manager | -|---|---|---| -| `Cargo.toml` | Rust | cargo | -| `package.json` | Node.js / TypeScript | npm / yarn / pnpm (check lockfile) | -| `pyproject.toml` | Python | pip / uv / poetry (check `[build-system]` or `[tool.poetry]`) | -| `requirements.txt` | Python | pip | -| `setup.py` / `setup.cfg` | Python | pip | -| `pubspec.yaml` | Dart / Flutter | pub | -| `*.csproj` / `*.fsproj` / `*.sln` | C# / F# | NuGet (dotnet) | -| `Directory.Build.props` | C# / F# | NuGet (dotnet) | -| `go.mod` | Go | go modules | -| `Gemfile` | Ruby | bundler | -| `composer.json` | PHP | composer | -| `build.gradle` / `build.gradle.kts` | Java / Kotlin | gradle | -| `pom.xml` | Java | maven | +| Manifest | Ecosystem | Outdated | Upgrade (semver) | Upgrade (`--major`) | Audit | Override mechanism | +|---|---|---|---|---|---|---| +| `package.json` (npm) | Node | `npm outdated` | `npm update` | `npx npm-check-updates -u && npm install` | `npm audit --json` | `"overrides"` in package.json | +| `package.json` (pnpm) | Node | `pnpm outdated` | `pnpm update` | `pnpm update --latest` | `pnpm audit --json` | `"pnpm.overrides"` | +| `package.json` (yarn) | Node | `yarn outdated` | `yarn up` | `yarn up -R '**'` | `yarn npm audit --json` | `"resolutions"` | +| `Cargo.toml` | Rust | `cargo outdated` | `cargo update` | `cargo update --breaking` | `cargo audit` | `[patch.crates-io]` in Cargo.toml | +| `pyproject.toml` / `requirements.txt` | Python (pip/uv/poetry) | `pip list --outdated` · `uv pip list --outdated` · `poetry show --outdated` | `pip install -U -r requirements.txt` · `uv lock --upgrade` · `poetry update` | edit specifiers · `uv lock --upgrade` · `poetry update --latest` | `pip-audit --strict` | pin in `requirements.txt` / `constraints.txt` / `>=` in pyproject | +| `*.csproj` / `Directory.Build.props` | .NET (NuGet) | `dotnet list package --outdated --include-transitive` | `dotnet add package ` (per package) or `dotnet outdated --upgrade` | same | `dotnet list package --vulnerable --include-transitive` | explicit `` in consuming project, or `` in `Directory.Packages.props` | +| `go.mod` | Go | `go list -m -u all` | `go get -u ./... && go mod tidy` | same | `govulncheck ./...` | `replace` directive in go.mod | +| `Gemfile` | Ruby | `bundle outdated` | `bundle update` | edit Gemfile constraints then `bundle update` | `bundle audit check --update` | explicit version constraint in Gemfile | +| `composer.json` | PHP | `composer outdated` | `composer update` | edit constraints then `composer update` | `composer audit` | explicit version in composer.json | +| `pubspec.yaml` | Dart/Flutter | `dart pub outdated` | `dart pub upgrade` | `dart pub upgrade --major-versions` | `dart pub deps` + check https://osv.dev | explicit version in pubspec.yaml | +| `build.gradle(.kts)` | Gradle | `./gradlew dependencyUpdates` | edit versions then `./gradlew dependencies` | same | `./gradlew dependencyCheckAnalyze` (OWASP) | version catalog entry | +| `pom.xml` | Maven | `mvn versions:display-dependency-updates` | `mvn versions:use-latest-releases && mvn versions:commit` | same | OWASP `dependency-check` | explicit `` in pom.xml | -If multiple languages are present, process each one in order. +Install scanner binaries if missing (`cargo install cargo-audit`, `pip install pip-audit`, `go install golang.org/x/vuln/cmd/govulncheck@latest`, `gem install bundler-audit`). Do not skip a scan because the tool is missing. -**If you cannot detect any manifest file, stop and tell the user.** +Authoritative upgrade docs (fetch with WebFetch before running anything non-obvious): [npm](https://docs.npmjs.com/cli/v10/commands/npm-update), [pnpm](https://pnpm.io/cli/update), [yarn](https://yarnpkg.com/cli/up), [cargo](https://doc.rust-lang.org/cargo/commands/cargo-update.html), [pip](https://pip.pypa.io/en/stable/cli/pip_install/), [uv](https://docs.astral.sh/uv/reference/cli/), [poetry](https://python-poetry.org/docs/cli/#update), [dotnet](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-add-package), [go](https://go.dev/ref/mod#go-get), [bundler](https://bundler.io/man/bundle-update.1.html), [composer](https://getcomposer.org/doc/03-cli.md#update-u-upgrade), [pub](https://dart.dev/tools/pub/cmd/pub-outdated), [gradle](https://docs.gradle.org/current/userguide/dependency_management.html), [maven](https://www.mojohaus.org/versions/versions-maven-plugin/). -## Step 2 — List outdated packages - -Run the appropriate command to list what's outdated BEFORE upgrading anything. Show the user what will change. - -### Rust -```bash -cargo outdated # install: cargo install cargo-outdated -cargo update --dry-run -``` -**Read the docs:** https://doc.rust-lang.org/cargo/commands/cargo-update.html - -### Node.js (npm) -```bash -npm outdated -``` -If using yarn: `yarn outdated`. If using pnpm: `pnpm outdated`. - -**Read the docs:** -- npm: https://docs.npmjs.com/cli/v10/commands/npm-update -- yarn: https://yarnpkg.com/cli/up -- pnpm: https://pnpm.io/cli/update - -### Python (pip) -```bash -pip list --outdated -``` -If using uv: `uv pip list --outdated`. If using poetry: `poetry show --outdated`. - -**Read the docs:** -- pip: https://pip.pypa.io/en/stable/cli/pip_install/#cmdoption-U -- uv: https://docs.astral.sh/uv/reference/cli/#uv-pip-install -- poetry: https://python-poetry.org/docs/cli/#update - -### Dart / Flutter -```bash -dart pub outdated -# or for Flutter projects: -flutter pub outdated -``` -**Read the docs:** https://dart.dev/tools/pub/cmd/pub-outdated - -### C# / F# (NuGet) -```bash -dotnet list package --outdated -``` -For transitive dependencies too: `dotnet list package --outdated --include-transitive` - -**Read the docs:** https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-list-package - -### Go -```bash -go list -m -u all -``` -**Read the docs:** https://go.dev/ref/mod#go-get - -### Ruby (Bundler) -```bash -bundle outdated -``` -**Read the docs:** https://bundler.io/man/bundle-update.1.html +## Step 1 — Detect ecosystems -### PHP (Composer) -```bash -composer outdated -``` -**Read the docs:** https://getcomposer.org/doc/03-cli.md#update-u-upgrade +Scan repo root and subdirectories for manifest files listed above. If none, stop and tell the user. -### Java / Kotlin (Gradle) -```bash -./gradlew dependencyUpdates # requires ben-manes/gradle-versions-plugin -``` -**Read the docs:** https://docs.gradle.org/current/userguide/dependency_management.html - -### Java (Maven) -```bash -mvn versions:display-dependency-updates -``` -**Read the docs:** https://www.mojohaus.org/versions/versions-maven-plugin/display-dependency-updates-mojo.html - -If `--check-only` was passed, **stop here** and report the outdated list. - -## Step 3 — Read the official upgrade docs - -**Before running any upgrade command, you MUST fetch and read the official documentation URL listed above for the detected package manager.** Use WebFetch to retrieve the page. This ensures you use the correct flags and understand the behavior. Do not guess at flags or options from memory. - -## Step 4 — Upgrade packages - -Run the upgrade. If a specific package name was given as an argument, upgrade only that package. - -### Rust -```bash -cargo update # semver-compatible updates -# --major flag: -cargo update --breaking # major version bumps (cargo 1.84+) -``` -For workspace members, run from workspace root. - -### Node.js (npm) -```bash -npm update # semver-compatible (within package.json ranges) -# --major flag: -npx npm-check-updates -u && npm install # bump package.json to latest majors -``` -If using yarn: `yarn up` / `yarn up -R '**'`. If using pnpm: `pnpm update` / `pnpm update --latest`. - -### Python (pip) -For `requirements.txt`: -```bash -pip install --upgrade -r requirements.txt -pip freeze > requirements.txt # pin new versions -``` -For `pyproject.toml` with pip: update version specifiers manually, then `pip install -e ".[dev]"`. -For uv: `uv pip install --upgrade -r requirements.txt` or `uv lock --upgrade`. -For poetry: `poetry update` / `poetry update --latest` (with `--major` flag). - -### Dart / Flutter -```bash -dart pub upgrade # semver-compatible -# --major flag: -dart pub upgrade --major-versions # bump to latest majors -``` -For Flutter: replace `dart` with `flutter`. +## Step 2 — List outdated packages -### C# / F# (NuGet) -There is NO single `dotnet upgrade-all` command. You must upgrade each package individually: -```bash -# For each outdated package from Step 2: -dotnet add package # upgrades to latest -# Or with specific version: -dotnet add package --version -``` -For `Directory.Build.props`, edit the version numbers directly in the XML. +Run the "Outdated" column command for each detected ecosystem and show the diff to the user. If `--check-only`, stop here. -**Read the docs:** https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-add-package +## Step 3 — Upgrade -Alternatively, use the dotnet-outdated global tool: -```bash -dotnet tool install --global dotnet-outdated-tool -dotnet outdated --upgrade -``` -**Read the docs:** https://github.com/dotnet-outdated/dotnet-outdated +Run the "Upgrade (semver)" column, or "Upgrade (`--major`)" if `--major` was passed. If a package name argument was given, scope the upgrade to that package. -### Go -```bash -go get -u ./... # update all dependencies -go mod tidy # clean up go.sum -``` -For a specific package: `go get -u @latest`. +## Step 4 — Build & test -### Ruby (Bundler) -```bash -bundle update # all gems -# specific gem: -bundle update -``` +Run `make ci` (or the project's build/test commands from the Makefile, CI workflow, or CLAUDE.md). Fix any breakage using release notes / migration guides. If stuck after 3 attempts on the same failure, stop and report. -### PHP (Composer) -```bash -composer update # all packages -# specific package: -composer update -``` -With `--major`: edit `composer.json` version constraints first, then `composer update`. +## Step 5 — Vulnerability scan (MANDATORY) -### Java / Kotlin (Gradle) -Edit version numbers in `build.gradle` / `build.gradle.kts` / version catalogs (`libs.versions.toml`), then: -```bash -./gradlew dependencies # verify resolution -``` +Upgrading top-level deps does NOT guarantee transitive deps are clean. Drive the count to **zero**. -### Java (Maven) -```bash -mvn versions:use-latest-releases # update pom.xml to latest releases -mvn versions:commit # remove backup pom -``` +**5a. Scan** — run the "Audit" column for each ecosystem. -## Step 5 — Verify the upgrade +**5b. Read runtime constraints** — before pinning anything: +- npm: `engines.node` + `.github/workflows/*.yml` `node-version:` + `.nvmrc` +- Python: `requires-python` / `.python-version` +- .NET: `` in every project +- Go: `go` directive in `go.mod` +- Rust: `rust-version` in `Cargo.toml` +- Ruby: `.ruby-version` / `ruby` directive in Gemfile -After upgrading, run the project's build and test suite to confirm nothing broke: +**5c. For each advisory, pick the highest compatible fix:** +1. Look up the fixed-version range (scanner output, else [osv.dev](https://osv.dev) or [github.com/advisories](https://github.com/advisories)). +2. List published versions newest-first (`npm view versions`, `pip index versions `, `cargo search `, `dotnet package search `, `gem info `). +3. Pick the newest version that is both in the fix range AND satisfies 5b's runtime floor. Prefer a major jump only when no lower major has a fix. -```bash -make ci -``` +**5d. Apply via the "Override mechanism" column.** Scope narrowly when consumers disagree on major (e.g. `eslint` → `ajv@6` vs `secretlint` → `ajv@8`: override to `"parent > pkg"` not top-level). -If `make ci` is not available, run whatever build/test commands the project uses (check the Makefile, CI workflow, or CLAUDE.md). +**5e. Re-install, re-scan, iterate.** Loop 5c–5e until zero. If a previous override broke Step 4, re-scope and re-run Step 4. -If tests fail: -1. Read the failure output carefully -2. Check the changelog / migration guide for the upgraded packages (fetch the release notes URL if available) -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 with the error details and the package that caused it +**5f. If zero is impossible** (no fix exists, or only fix needs an unauthorised runtime bump): list each residual advisory with package, installed version, advisory ID, severity, available fix version, reason not applied, recommended action. Do NOT suppress via `--omit=dev`, `audit.level`, allowlists, `--ignore-vuln`, etc. ## Step 6 — Report -Provide a summary: - -- Packages upgraded (old version -> new version) -- Packages skipped (and why, e.g., major version bump without `--major` flag) -- Build/test result after upgrade -- Any breaking changes that were fixed -- Any packages that could not be upgraded (with error details) +- Packages upgraded (old → new) +- Packages skipped (and why — major without `--major`, etc.) +- Transitive overrides applied for security (package, version, scope, advisory IDs fixed) +- Residual advisories with justification (from 5f), if any +- Build/test result ## Rules -- **Always list outdated packages first** before upgrading anything -- **Always read the official docs** for the package manager before running upgrade commands -- **Always run tests after upgrading** to catch breakage immediately -- **Never remove packages** unless they were explicitly deprecated and replaced -- **Never downgrade packages** unless rolling back a broken upgrade -- **Never modify lockfiles manually** (package-lock.json, yarn.lock, Cargo.lock, etc.) — let the package manager regenerate them -- **Commit nothing** — leave changes in the working tree for the user to review +- List outdated first. Run tests after upgrading. Run the Step 5 scan — mandatory. +- Never remove packages unless deprecated and replaced. Never modify lockfiles manually — let the package manager regenerate. +- Never downgrade except to roll back a broken upgrade, or to pin a transitive via override for a security fix (Step 5d). +- Never suppress vulnerability reports (`--omit=dev`, `audit.level`, `.audit-ci.json`, `--ignore-vuln`, etc.) — fix them. +- Commit nothing — leave changes in the working tree. ## 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 +- Outdated packages upgraded to latest compatible (or latest major if `--major`). +- Build and tests pass. +- Vulnerability scanner reports **zero** advisories, or every residual one is listed and justified per 5f. +- Report includes any transitive overrides applied for security. diff --git a/.claude/skills/website-audit/SKILL.md b/.claude/skills/website-audit/SKILL.md new file mode 100644 index 0000000..4aa694a --- /dev/null +++ b/.claude/skills/website-audit/SKILL.md @@ -0,0 +1,181 @@ +--- +name: website-audit +description: Audits a website for SEO, AI search performance, structured data, mobile usability, broken links, and social media cards. Fixes issues found. Use when the user mentions "audit website", "SEO", "fix search ranking", "AI search", "structured data", "social media cards", or "website performance". +--- + + +# Website Audit + +Performs a comprehensive website audit and fixes issues affecting search visibility and AI discoverability. + +Copy this checklist and track your progress: + +``` +Audit Progress: +- [ ] Step 1: Read guidelines +- [ ] Step 2: Audit AI search readiness +- [ ] Step 3: Audit SEO and keywords +- [ ] Step 4: Audit crawling and indexing +- [ ] Step 5: Audit broken links and canonicalization +- [ ] Step 6: Audit mobile usability +- [ ] Step 7: Audit structured data +- [ ] Step 8: Audit social media cards +- [ ] Step 9: Audit For Unsubstantiated Claims +- [ ] Step 10: Audit Design Compliance +- [ ] Step 11: Test with Playwright +- [ ] Step 12: Report findings +``` + +- Check the outputted HTML/CSS/JavaScript AFTER the website is generated by the static content generator. +- Don't just check the static content before the website is generated. +- Fix issues at the core where the static content templates are stored - not in the outputted HTML (e.g. _site) +- Never manually edit the generated website content directly +- ENSURE THE FOOTER HAS A copyright link to nimblesite.co + +## Step 1 — Read guidelines + +Fetch and read each of these before auditing. These are the authoritative references for every step that follows. + +- [Google's guidance on using generative AI content](https://developers.google.com/search/docs/fundamentals/using-gen-ai-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) + +If the repo has a business plan doc, take it into account + +Identify the website source files in the repo. Determine the framework (static site generator, Next.js, Hugo, etc.) so you know where to find templates, metadata, and content. + +## Step 2 — Audit AI search readiness + +Apply the guidance from the AI search article. Check: + +1. **Content quality** — Is content original, expert-level, and comprehensive? Flag thin or duplicated pages. +2. **Clear structure** — Do pages use descriptive headings, lists, and concise answers to likely questions? +3. **Entity clarity** — Are key terms, products, and concepts defined clearly so AI can extract them? +4. **Freshness signals** — Are dates, update timestamps, and authorship present? + +Fix issues directly in the source files. For each fix, note what changed and why. + +## Step 3 — Audit SEO and keywords + +1. Search [Google Trends](https://trends.google.com/home) for trending keywords related to the website's content. +2. Review each page's ``, `<meta name="description">`, and `<h1>` tags. +3. Check for keyword opportunities — can trending terms be naturally inserted into headings, descriptions, or body content? +4. Verify each page has a unique, descriptive title (50-60 chars) and meta description (150-160 chars). +5. Check image `alt` attributes describe the image content and include relevant keywords where natural. + +Apply the [SEO Starter Guide](https://developers.google.com/search/docs/fundamentals/seo-starter-guide) principles. Fix issues directly. + +## Step 4 — Audit crawling and indexing + +Reference: [Overview of crawling and indexing topics](https://developers.google.com/search/docs/crawling-indexing) + +1. **robots.txt** — Locate and review it. Verify it doesn't block important pages. Reference: [robots.txt spec](https://developers.google.com/search/docs/crawling-indexing/robots-txt) +2. **Sitemap** — Locate the sitemap (or sitemap index). Verify all important pages are listed and no dead URLs are included. Reference: [Sitemap guidelines](https://developers.google.com/search/docs/crawling-indexing/sitemaps/large-sitemaps) +3. **Meta robots tags** — Check for unintended `noindex` or `nofollow` directives on pages that should be indexed. + +Note: robots.txt and sitemaps are often auto-generated. If so, check the generator config rather than the output file. + +## Step 5 — Audit broken links and canonicalization + +Reference: [What is canonicalization](https://developers.google.com/search/docs/crawling-indexing/canonicalization) + +1. Check all internal links resolve to valid pages (no 404s). +2. Verify `<link rel="canonical">` tags are present and point to the correct URL. +3. Check for duplicate content accessible via multiple URLs (with/without trailing slash, www vs non-www). +4. Verify redirects use 301 (permanent) not 302 (temporary) where appropriate. + +## Step 6 — Audit mobile usability + +Reference: [Mobile-first indexing best practices](https://developers.google.com/search/docs/crawling-indexing/mobile/mobile-sites-mobile-first-indexing) + +1. Verify the `<meta name="viewport">` tag is present and correctly configured. +2. Check that content is identical between mobile and desktop (mobile-first indexing requires this). +3. Verify touch targets are adequately sized (min 48x48px). +4. Check font sizes are readable without zooming (min 16px body text). + +## Step 7 — Audit structured data + +Reference: [Structured data guidelines](https://developers.google.com/search/docs/appearance/structured-data/sd-policies) + +1. Check for existing JSON-LD `<script type="application/ld+json">` blocks. +2. Verify the structured data matches the page content (no misleading markup). +3. Add missing structured data where appropriate: + - **Organization/Person** on the homepage + - **Article/BlogPosting** on blog posts (with author, datePublished, dateModified) + - **BreadcrumbList** for navigation + - **FAQ** for pages with question/answer content +4. Validate JSON-LD syntax is correct. + +## Step 8 — Audit social media cards + +Reference: [Implementing Social Media Preview Cards](https://documentation.platformos.com/use-cases/implementing-social-media-preview-cards) + +Check every page template includes: + +**Open Graph (Facebook/LinkedIn):** +- `og:title`, `og:description`, `og:image`, `og:url`, `og:type` + +**Twitter Card:** +- `twitter:card`, `twitter:title`, `twitter:description`, `twitter:image` + +Verify `og:image` dimensions are at least 1200x630px. Fix missing or incomplete tags. + +## Step 9 - Audit For Unsubstantiated Claims + +Ensure that all claims are backed up with a link to a reputable source. As an example, this claim isn't valid as content unless it links to an authority that found this through research + +> Research shows teams with strong DevEx perform 4-5x better across speed, quality, and engagement + +Search for the authoritative URL and add a link to the URL. If it is not available, change the claim to something that can be substatiated. + +## Step 10 — Audit Design Compliance + +Read the design system docs and view the design screens in the designsystem folder. + +## Step 11 — Test with Playwright + +Build and run the website locally using `make website-run` (or the project's equivalent dev server command). + +**Desktop tests (1280x720):** + +1. Navigate to the homepage — take a screenshot. +2. Navigate to each major section — verify pages load without errors. +3. Check the browser console for JavaScript errors. +4. Verify all navigation links work. + +**Mobile tests (375x667, iPhone SE):** + +1. Resize the browser to mobile dimensions. +2. Navigate to the homepage — take a screenshot. +3. Verify the layout is responsive (no horizontal overflow, readable text). +4. Test navigation menu (hamburger menu if applicable). + +If any page fails to load or has console errors, fix the issue and retest. + +## Step 12 — Report findings + +Summarize the audit results: + +``` +## Website Audit Report + +### Fixed +- [List each issue fixed with file and line reference] + +### Warnings (manual review needed) +- [Issues that need human judgment] + +### Passed +- [Areas that passed audit with no issues] + +### Screenshots +- [Reference Playwright screenshots taken] +``` + +## Rules + +- **Fix issues directly** — don't just report them. Only flag issues as warnings when they require human judgment (e.g., content tone, keyword selection). +- **One step at a time** — complete each step before moving to the next. +- **Preserve existing content** — improve structure and metadata without rewriting the author's voice. +- **No keyword stuffing** — keywords must read naturally in context. +- **Respect the framework** — edit templates/configs, not generated output files. diff --git a/.codex/skills/ci-prep b/.codex/skills/ci-prep new file mode 120000 index 0000000..b1d1ff2 --- /dev/null +++ b/.codex/skills/ci-prep @@ -0,0 +1 @@ +../../.claude/skills/ci-prep \ No newline at end of file diff --git a/.codex/skills/code-dedup b/.codex/skills/code-dedup new file mode 120000 index 0000000..df20e20 --- /dev/null +++ b/.codex/skills/code-dedup @@ -0,0 +1 @@ +../../.claude/skills/code-dedup \ No newline at end of file diff --git a/.codex/skills/fix-bug b/.codex/skills/fix-bug new file mode 120000 index 0000000..d48063b --- /dev/null +++ b/.codex/skills/fix-bug @@ -0,0 +1 @@ +../../.claude/skills/fix-bug \ No newline at end of file diff --git a/.codex/skills/spec-check b/.codex/skills/spec-check new file mode 120000 index 0000000..c5367ee --- /dev/null +++ b/.codex/skills/spec-check @@ -0,0 +1 @@ +../../.claude/skills/spec-check \ No newline at end of file diff --git a/.codex/skills/submit-pr b/.codex/skills/submit-pr new file mode 120000 index 0000000..e124427 --- /dev/null +++ b/.codex/skills/submit-pr @@ -0,0 +1 @@ +../../.claude/skills/submit-pr \ No newline at end of file diff --git a/.codex/skills/upgrade-packages b/.codex/skills/upgrade-packages new file mode 120000 index 0000000..32cdcda --- /dev/null +++ b/.codex/skills/upgrade-packages @@ -0,0 +1 @@ +../../.claude/skills/upgrade-packages \ No newline at end of file diff --git a/.codex/skills/website-audit b/.codex/skills/website-audit new file mode 120000 index 0000000..2ce8a2e --- /dev/null +++ b/.codex/skills/website-audit @@ -0,0 +1 @@ +../../.claude/skills/website-audit \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 78be060..23f4d63 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,8 +1,10 @@ -# TLDR; +<!-- agent-pmo:f481f8d --> +## TLDR +<!-- One sentence: what does this PR do? --> +## Details +<!-- New functionality, new files, new dependencies. What changed? --> -# Details - - -# How do the tests prove the change works - +## How Do The Automated Tests Prove It Works? +<!-- Name specific tests or describe what the test output demonstrates. --> +<!-- "Tests pass" is not acceptable. Be specific. --> diff --git a/.gitignore b/.gitignore index f7afb92..20ebfa6 100644 --- a/.gitignore +++ b/.gitignore @@ -80,7 +80,4 @@ build/ # ============================================================================= .venv/ website/_site/ -test-results/ - - -.claude/skills/website-audit/SKILL.md \ No newline at end of file +test-results/ \ No newline at end of file diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 86d39be..688a87f 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,48 +1,50 @@ -import { defineConfig } from '@vscode/test-cli'; -import { cpSync, mkdtempSync } from 'fs'; -import { tmpdir } from 'os'; -import { join, resolve } from 'path'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; +import { defineConfig } from "@vscode/test-cli"; +import { cpSync, mkdtempSync } from "fs"; +import { tmpdir } from "os"; +import { join, resolve } from "path"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; const __dirname = dirname(fileURLToPath(import.meta.url)); // Copy fixtures to a temp directory so tests run in full isolation -const testWorkspace = mkdtempSync(join(tmpdir(), 'commandtree-test-')); -cpSync('./src/test/fixtures/workspace', testWorkspace, { recursive: true }); +const testWorkspace = mkdtempSync(join(tmpdir(), "commandtree-test-")); +cpSync("./src/test/fixtures/workspace", testWorkspace, { recursive: true }); -const userDataDir = resolve(__dirname, '.vscode-test/user-data'); +const userDataDir = resolve(__dirname, ".vscode-test/user-data"); export default defineConfig({ - tests: [{ - files: ['out/test/e2e/**/*.test.js', 'out/test/providers/**/*.test.js'], - version: 'stable', - workspaceFolder: testWorkspace, - extensionDevelopmentPath: './', - srcDir: __dirname, - mocha: { - ui: 'tdd', - timeout: 60000, - color: true, - slow: 10000 - }, - launchArgs: [ - '--user-data-dir', userDataDir - ] - }], - coverage: { - 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/**', - '**/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' - } + tests: [ + { + files: ["out/test/e2e/**/*.test.js", "out/test/providers/**/*.test.js", "out/test/unit/**/*.test.js"], + version: "stable", + workspaceFolder: testWorkspace, + extensionDevelopmentPath: "./", + srcDir: __dirname, + mocha: { + ui: "tdd", + bail: true, + timeout: 60000, + color: true, + slow: 10000, + }, + launchArgs: ["--user-data-dir", userDataDir], + }, + ], + coverage: { + 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/**", + "**/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/semantic/adapters.js", // type-only interfaces, no runtime behavior + ], + reporter: ["text", "lcov", "html", "json-summary"], + output: "./coverage", + }, }); diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 3983fa0..10b81a9 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,5 @@ { - "_agent_pmo": "424c8f8", + "_agent_pmo": "f481f8d", "recommendations": [ "nimblesite.commandtree", "nimblesite.too-many-cooks", diff --git a/.vscodeignore b/.vscodeignore index bbb5e26..9e72aa2 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,6 +1,17 @@ .vscode/** .vscode-test/** .venv/** +.cache/** +.clinerules/** +.commandtree/** +.codex/** +.cursorrules +.mcp.json +.nyc_output/** +.playwright-mcp/** +.windsurfrules +build/** +dist/** src/** test-fixtures/** out/test/** @@ -10,16 +21,29 @@ tools/** .too_many_cooks/** .claude/** .github/** +.vscodeignore +.prettierrc* .gitignore .mocharc.json .vscode-test.mjs cspell.json tsconfig.json eslint.config* +Agents.md +AGENTS.md icon.svg CLAUDE.md Claude.md SPEC.md +Makefile +coverage/** +coverage-thresholds.json +docs/** +dotnet-tools.json +logs/** +opencode.json +scratch/** +test-results/** **/*.map **/*.ts **/*.bak diff --git a/Agents.md b/Agents.md index e186551..02a6bd2 100644 --- a/Agents.md +++ b/Agents.md @@ -1,2 +1,2 @@ -<!-- agent-pmo:424c8f8 --> -@CLAUDE.md +<!-- agent-pmo:f481f8d --> +Read and follow all instructions in CLAUDE.md before writing any code. diff --git a/Claude.md b/Claude.md index b70a591..a67776e 100644 --- a/Claude.md +++ b/Claude.md @@ -64,12 +64,11 @@ CommandTree is a VS Code extension that discovers and organizes runnable tasks ( ## 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 +- Register immediately: descriptive name, intent, files you will touch +- Before editing any file: lock it via TMC; release locks immediately when done +- Broadcast your plan before starting work +- Check messages every few minutes, and respond +- Never edit a locked file — wait or find another approach ### CSS @@ -115,14 +114,6 @@ Only test the UI **THROUGH the UI**. Do not run commands etc. to coerce the stat - Run tests to verify test passes - Repeat and fix until test passes WITHOUT changing the test -### 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 @@ -197,24 +188,6 @@ CommandTree/ └── .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.filterByTag` | Tag filter picker | -| `commandtree.clearFilter` | Clear all filters | -| `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 | - ## Adding New Task Types 1. Create discovery module in `src/discovery/` diff --git a/Makefile b/Makefile index abe3f88..8cf14c1 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,14 @@ # Cross-platform: Linux, macOS, Windows (via GNU Make) # ============================================================================= -.PHONY: build test lint fmt clean ci setup package test-exclude-ci help +.PHONY: build test lint fmt clean ci setup package test-exclude-ci reinstall help + +# Extension identity — keep in sync with package.json +EXT_PUBLISHER := nimblesite +EXT_NAME := commandtree +EXT_ID := $(EXT_PUBLISHER).$(EXT_NAME) +EXT_VERSION := $(shell node -p "require('./package.json').version") +VSIX_FILE := $(EXT_NAME)-$(EXT_VERSION).vsix # --------------------------------------------------------------------------- # OS Detection @@ -27,13 +34,10 @@ endif COVERAGE_THRESHOLDS_FILE := coverage-thresholds.json UNAME := $(shell uname 2>/dev/null) -VSCODE_TEST_CMD = npx vscode-test --coverage -VSCODE_TEST_EXCLUDE_CMD = npx vscode-test --coverage --grep @exclude-ci --invert +VSCODE_TEST_EXCLUDE_CMD = npx vscode-test --coverage --bail --grep @exclude-ci --invert ifeq ($(UNAME),Linux) -VSCODE_TEST = xvfb-run -a $(VSCODE_TEST_CMD) VSCODE_TEST_EXCLUDE = xvfb-run -a $(VSCODE_TEST_EXCLUDE_CMD) else -VSCODE_TEST = $(VSCODE_TEST_CMD) VSCODE_TEST_EXCLUDE = $(VSCODE_TEST_EXCLUDE_CMD) endif @@ -48,9 +52,9 @@ build: ## test: Fail-fast tests + coverage + threshold enforcement ([TEST-RULES]). test: build - @echo "==> Testing (fail-fast + coverage + threshold)..." + @echo "==> Testing (excluding @exclude-ci, fail-fast + coverage + threshold)..." npm run test:unit - $(VSCODE_TEST) + $(VSCODE_TEST_EXCLUDE) $(MAKE) _coverage_check ## lint: Run all linters/analyzers (read-only). Does NOT format. @@ -90,12 +94,41 @@ _coverage_check: package: build npx vsce package -## test-exclude-ci: Run tests EXCLUDING those tagged @exclude-ci (fail-fast + coverage + threshold) -test-exclude-ci: build - @echo "==> Testing (excluding @exclude-ci, fail-fast + coverage + threshold)..." - npm run test:unit - $(VSCODE_TEST_EXCLUDE) - $(MAKE) _coverage_check +## test-exclude-ci: Alias for `test`; kept for existing CI workflows. +test-exclude-ci: test + +## reinstall: Full clean rebuild — uninstall extension, wipe artifacts + VSIX + node_modules, reinstall deps, package, install +reinstall: + @echo "==> Uninstalling $(EXT_ID) from VS Code..." + -code --uninstall-extension $(EXT_ID) + @echo "==> Cleaning build artifacts and existing VSIX files..." + $(RM) out coverage node_modules + $(RM) *.vsix +ifeq ($(OS),Windows_NT) + -$(RM) .vscode-test +else + bash -c 'set -e; \ + dir="$(PWD)/.vscode-test"; \ + [ -d "$$dir" ] || exit 0; \ + echo "==> Killing processes holding files in .vscode-test..."; \ + pids=$$(lsof +D "$$dir" 2>/dev/null | awk "NR>1 {print \$$2}" | sort -u); \ + if [ -n "$$pids" ]; then \ + echo " SIGTERM: $$pids"; kill $$pids 2>/dev/null || true; sleep 1; \ + pids=$$(lsof +D "$$dir" 2>/dev/null | awk "NR>1 {print \$$2}" | sort -u); \ + if [ -n "$$pids" ]; then echo " SIGKILL: $$pids"; kill -9 $$pids 2>/dev/null || true; sleep 1; fi; \ + fi; \ + chmod -R u+rwX "$$dir" 2>/dev/null || true; \ + for i in 1 2 3 4 5; do rm -rf "$$dir" && break || sleep 1; done; \ + [ ! -d "$$dir" ] || { echo "Failed to remove .vscode-test"; exit 1; }' +endif + @echo "==> Installing dependencies..." + npm ci + @echo "==> Building VSIX..." + npx tsc -p ./ + npx vsce package + @echo "==> Installing VSIX into VS Code..." + code --install-extension $(VSIX_FILE) + @echo "==> Reinstall complete." ## help: List available targets help: @@ -110,4 +143,5 @@ help: @echo "" @echo "Repo-specific:" @echo " package - Build VSIX package" - @echo " test-exclude-ci - Run tests excluding those tagged @exclude-ci" + @echo " test-exclude-ci - Alias for test" + @echo " reinstall - Full clean: uninstall, wipe everything, rebuild, package, install VSIX" diff --git a/coverage-thresholds.json b/coverage-thresholds.json index aabde1d..64c8f73 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -1,6 +1,8 @@ { - "lines": 84.88, - "functions": 84.07, - "branches": 79.83, - "statements": 84.88 + "_agent_pmo": "f481f8d", + "_doc": "Single source of truth for code coverage thresholds. See REPO-STANDARDS-SPEC [COVERAGE-THRESHOLDS-JSON]. Enforced by tools/check-coverage.mjs via `make test`. Ratchet UP only. Extended format (per-metric) overrides the spec's single default_threshold to enforce both line AND branch coverage per [COVERAGE-THRESHOLDS] (VS Code extension: 80% line / 70% branch — measured values here are well above).", + "lines": 92.11, + "functions": 93.87, + "branches": 87.33, + "statements": 92.11 } diff --git a/eslint.config.mjs b/eslint.config.mjs index 3b76c3a..1fbd70b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -41,7 +41,6 @@ export default tseslint.config( // TypeScript strict rules "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/explicit-function-return-type": "error", "@typescript-eslint/explicit-module-boundary-types": "error", "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-non-null-assertion": "error", @@ -62,7 +61,6 @@ export default tseslint.config( "@typescript-eslint/return-await": ["error", "always"], "@typescript-eslint/promise-function-async": "error", "@typescript-eslint/no-redundant-type-constituents": "error", - "@typescript-eslint/no-confusing-void-expression": "error", "@typescript-eslint/no-meaningless-void-operator": "error", "@typescript-eslint/prefer-readonly": "error", "@typescript-eslint/prefer-readonly-parameter-types": "off", // too aggressive for vscode api @@ -181,6 +179,10 @@ export default tseslint.config( "max-depth": ["error", 3], "max-params": ["error", 3], "complexity": ["error", 10], + + //These are actually harmful + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-confusing-void-expression": "off" }, }, ); diff --git a/package-lock.json b/package-lock.json index 75efa0d..e30a7f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,9 +98,9 @@ } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", - "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", + "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -109,7 +109,7 @@ "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.0", + "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" }, "engines": { @@ -145,9 +145,9 @@ } }, "node_modules/@azure/identity": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz", - "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", "dev": true, "license": "MIT", "dependencies": { @@ -158,8 +158,8 @@ "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^4.2.0", - "@azure/msal-node": "^3.5.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", "open": "^10.1.0", "tslib": "^2.2.0" }, @@ -182,22 +182,22 @@ } }, "node_modules/@azure/msal-browser": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.28.1.tgz", - "integrity": "sha512-al2u2fTchbClq3L4C1NlqLm+vwKfhYCPtZN2LR/9xJVaQ4Mnrwf5vANvuyPSJHcGvw50UBmhuVmYUAhTEetTpA==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.8.0.tgz", + "integrity": "sha512-X7IZV77bN56l7sbLjkcbQJX1t3U4tgxqztDr/XFbUcUfKk+z2FavcLgKP+OYUNj0wl/pEEtV9lldW9siY8BuHQ==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "15.14.1" + "@azure/msal-common": "16.5.1" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "15.14.1", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.14.1.tgz", - "integrity": "sha512-IkzF7Pywt6QKTS0kwdCv/XV8x8JXknZDvSjj/IccooxnP373T5jaadO3FnOrbWo3S0UqkfIDyZNTaQ/oAgRdXw==", + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.1.tgz", + "integrity": "sha512-WS9w9SfI8SEYO7mTnxGeZ3UwQfhAVYCWglYF2/7GNx3ioHiAs2gPkl9eSwVs8cPrmiGh+zi9ai/OOKoq4cyzDw==", "dev": true, "license": "MIT", "engines": { @@ -205,18 +205,18 @@ } }, "node_modules/@azure/msal-node": { - "version": "3.8.6", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.6.tgz", - "integrity": "sha512-XTmhdItcBckcVVTy65Xp+42xG4LX5GK+9AqAsXPXk4IqUNv+LyQo5TMwNjuFYBfAB2GTG9iSQGk+QLc03vhf3w==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.4.tgz", + "integrity": "sha512-G4LXGGggok1QC48uKu64/SV2DPRDlddmV8EieK8pflsNYMj9/Zz+Y9OHoEBhT15h+zpdwXXLYA/7PJCR/yZ8aw==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "15.14.1", + "@azure/msal-common": "16.5.1", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" }, "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@babel/code-frame": { @@ -926,13 +926,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.3", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", - "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.3", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" }, @@ -940,62 +940,23 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@eslint/config-helpers": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", - "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1" + "@eslint/core": "^1.2.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", - "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1027,9 +988,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", - "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1037,13 +998,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", - "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { @@ -1051,29 +1012,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1103,9 +1078,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -1366,28 +1341,28 @@ } }, "node_modules/@textlint/ast-node-types": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.5.1.tgz", - "integrity": "sha512-2ABQSaQoM9u9fycXLJKcCv4XQulJWTUSwjo6F0i/ujjqOH8/AZ2A0RDKKbAddqxDhuabVB20lYoEsZZgzehccg==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.5.4.tgz", + "integrity": "sha512-bVtB6VEy9U9DpW8cTt25k5T+lz86zV5w6ImePZqY1AXzSuPhqQNT77lkMPxonXzUducEIlSvUu3o7sKw3y9+Sw==", "dev": true, "license": "MIT" }, "node_modules/@textlint/linter-formatter": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.5.1.tgz", - "integrity": "sha512-7wfzpcQtk7TZ3UJO2deTI71mJCm4VvPGUmSwE4iuH6FoaxpdWpwSBiMLcZtjYrt/oIFOtNz0uf5rI+xJiHTFww==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.5.4.tgz", + "integrity": "sha512-D9qJedKBLmAo+kiudop4UKgSxXMi4O8U86KrCidVXZ9RsK0NSVIw6+r2rlMUOExq79iEY81FRENyzmNVRxDBsg==", "dev": true, "license": "MIT", "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "15.5.1", - "@textlint/resolver": "15.5.1", - "@textlint/types": "15.5.1", + "@textlint/module-interop": "15.5.4", + "@textlint/resolver": "15.5.4", + "@textlint/types": "15.5.4", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", @@ -1426,27 +1401,27 @@ } }, "node_modules/@textlint/module-interop": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.5.1.tgz", - "integrity": "sha512-Y1jcFGCKNSmHxwsLO3mshOfLYX4Wavq2+w5BG6x5lGgZv0XrF1xxURRhbnhns4LzCu0fAcx6W+3V8/1bkyTZCw==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.5.4.tgz", + "integrity": "sha512-JyAUd26ll3IFF87LP0uGoa8Tzw5ZKiYvGs6v8jLlzyND1lUYCI4+2oIAslrODLkf0qwoCaJrBQWM3wsw+asVGQ==", "dev": true, "license": "MIT" }, "node_modules/@textlint/resolver": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.5.1.tgz", - "integrity": "sha512-CVHxMIm8iNGccqM12CQ/ycvh+HjJId4RyC6as5ynCcp2E1Uy1TCe0jBWOpmLsbT4Nx15Ke29BmspyByawuIRyA==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.5.4.tgz", + "integrity": "sha512-5GUagtpQuYcmhlOzBGdmVBvDu5lKgVTjwbxtdfoidN4OIqblIxThJHHjazU+ic+/bCIIzI2JcOjHGSaRmE8Gcg==", "dev": true, "license": "MIT" }, "node_modules/@textlint/types": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.5.1.tgz", - "integrity": "sha512-IY1OVZZk8LOOrbapYCsaeH7XSJT89HVukixDT8CoiWMrKGCTCZ3/Kzoa3DtMMbY8jtY777QmPOVCNnR+8fF6YQ==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.5.4.tgz", + "integrity": "sha512-mY28j2U7nrWmZbxyKnRvB8vJxJab4AxqOobLfb6iozrLelJbqxcOTvBQednadWPfAk9XWaZVMqUr9Nird3mutg==", "dev": true, "license": "MIT", "dependencies": { - "@textlint/ast-node-types": "15.5.1" + "@textlint/ast-node-types": "15.5.4" } }, "node_modules/@types/esrecurse": { @@ -1496,13 +1471,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/normalize-package-data": { @@ -1520,21 +1495,145 @@ "license": "MIT" }, "node_modules/@types/vscode": { - "version": "1.110.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz", - "integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==", + "version": "1.116.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.116.0.tgz", + "integrity": "sha512-sYHp4MO6BqJ2PD7Hjt0hlIS3tMaYsVPJrd0RUjDJ8HtOYnyJIEej0bLSccM8rE77WrC+Xox/kdBwEFDO8MsxNA==", "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", - "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2" + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1542,30 +1641,86 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", "dev": true, "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", - "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1577,9 +1732,9 @@ } }, "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.3.tgz", - "integrity": "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", + "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", "dev": true, "license": "MIT", "dependencies": { @@ -1615,29 +1770,6 @@ "node": ">=18" } }, - "node_modules/@vscode/test-cli/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@vscode/test-cli/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@vscode/test-cli/node_modules/c8": { "version": "10.1.3", "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", @@ -1687,22 +1819,6 @@ "node": ">=18" } }, - "node_modules/@vscode/test-cli/node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@vscode/test-electron": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", @@ -1721,9 +1837,9 @@ } }, "node_modules/@vscode/vsce": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz", - "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==", + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.1.tgz", + "integrity": "sha512-MPn5p+DoudI+3GfJSpAZZraE1lgLv0LcwbH3+xy7RgEhty3UIkmUMUA+5jPTDaxXae00AnX5u77FxGM8FhfKKA==", "dev": true, "license": "MIT", "dependencies": { @@ -1754,7 +1870,7 @@ "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", - "yauzl": "^2.3.1", + "yauzl": "^3.2.1", "yazl": "^2.2.2" }, "bin": { @@ -1912,10 +2028,17 @@ "win32" ] }, + "node_modules/@vscode/vsce/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/@vscode/vsce/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1924,9 +2047,9 @@ } }, "node_modules/@vscode/vsce/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1970,9 +2093,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -2088,11 +2211,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -2189,13 +2315,16 @@ "license": "BSD-2-Clause" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -2793,19 +2922,6 @@ "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", @@ -3070,9 +3186,9 @@ } }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3213,9 +3329,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "dev": true, "license": "MIT", "dependencies": { @@ -3341,18 +3457,18 @@ } }, "node_modules/eslint": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", - "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.3", - "@eslint/core": "^1.1.1", - "@eslint/plugin-kit": "^0.6.1", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -3445,40 +3561,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" + "node": ">=10.13.0" } }, "node_modules/eslint/node_modules/json-schema-traverse": { @@ -3488,22 +3581,6 @@ "dev": true, "license": "MIT" }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/espree": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", @@ -3668,14 +3745,22 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "dependencies": { - "pend": "~1.2.0" + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/file-entry-cache": { @@ -3795,9 +3880,9 @@ "optional": true }, "node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "dev": true, "license": "MIT", "dependencies": { @@ -3855,9 +3940,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "dev": true, "license": "MIT", "engines": { @@ -3945,45 +4030,6 @@ "node": ">= 6" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "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", @@ -4000,16 +4046,6 @@ "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", @@ -4101,9 +4137,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, "license": "MIT", "dependencies": { @@ -4338,12 +4374,14 @@ "license": "ISC" }, "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "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", - "optional": true + "engines": { + "node": "^20.17.0 || >=22.9.0" + } }, "node_modules/is-binary-path": { "version": "2.1.0", @@ -4496,9 +4534,9 @@ } }, "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, "license": "MIT", "dependencies": { @@ -4657,9 +4695,9 @@ "license": "MIT" }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4812,9 +4850,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -4921,9 +4959,9 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "dev": true, "license": "MIT", "dependencies": { @@ -5043,16 +5081,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5200,9 +5238,9 @@ "license": "MIT" }, "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", "dev": true, "license": "MIT", "optional": true, @@ -5236,9 +5274,9 @@ } }, "node_modules/node-sqlite3-wasm": { - "version": "0.8.55", - "resolved": "https://registry.npmjs.org/node-sqlite3-wasm/-/node-sqlite3-wasm-0.8.55.tgz", - "integrity": "sha512-C2m7JzZgKiv9XVZ1ts9oPmS56PCvyHeQffTOF2KNO2TVZzq5IW2s+NFeEZn+eP6bnAuD2We/O9cOJSjQVf7Xxw==", + "version": "0.8.56", + "resolved": "https://registry.npmjs.org/node-sqlite3-wasm/-/node-sqlite3-wasm-0.8.56.tgz", + "integrity": "sha512-piu6QQNAm8cg6IK8vuI4zdMwlMjhay6Xb5gg3gc5pIMY8w75ZqXOtZKMy74tp8w4rhykyC/3s8fElWhWLntRtQ==", "license": "MIT" }, "node_modules/normalize-package-data": { @@ -5675,9 +5713,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5712,13 +5750,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "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": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -5738,6 +5776,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "dev": true, "license": "MIT", "optional": true, @@ -5773,9 +5812,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -5796,9 +5835,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, "license": "MIT", "optional": true, @@ -5828,9 +5867,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5864,16 +5903,6 @@ ], "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -5892,18 +5921,26 @@ } }, "node_modules/rc-config-loader": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.3.tgz", - "integrity": "sha512-kD7FqML7l800i6pS6pvLyIE2ncbk9Du8Q0gp/4hMPhJU6ZxApkoLcGD8ZeqgiAlfwZ6BlETq6qqe+12DUL207w==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.3.4", - "js-yaml": "^4.1.0", - "json5": "^2.2.2", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", "require-from-string": "^2.0.2" } }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -6121,9 +6158,9 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -6166,13 +6203,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/setimmediate": { @@ -6226,14 +6263,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -6417,9 +6454,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, "license": "CC0-1.0" }, @@ -6492,13 +6529,13 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -6614,9 +6651,9 @@ } }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -6707,45 +6744,6 @@ "node": "20 || >=22" } }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -6770,14 +6768,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -6786,37 +6784,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/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/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -6937,16 +6904,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", - "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", + "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.2", - "@typescript-eslint/parser": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2" + "@typescript-eslint/eslint-plugin": "8.59.0", + "@typescript-eslint/parser": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6957,226 +6924,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", - "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/type-utils": "8.57.2", - "@typescript-eslint/utils": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.57.2", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", - "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", - "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", - "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.2", - "@typescript-eslint/types": "^8.57.2", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", - "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", - "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/typescript-eslint/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/typescript-eslint/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/typescript-eslint/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/uc.micro": { @@ -7187,16 +6935,16 @@ "license": "MIT" }, "node_modules/underscore": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", - "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "dev": true, "license": "MIT" }, "node_modules/undici": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", - "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { @@ -7204,9 +6952,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, @@ -7258,13 +7006,17 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -7558,14 +7310,17 @@ } }, "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.0.tgz", + "integrity": "sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ==", "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/yazl": { diff --git a/package.json b/package.json index 9253957..2f9a3f6 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,21 @@ "title": "Open Preview", "icon": "$(open-preview)" }, + { + "command": "commandtree.copyRelativePath", + "title": "Copy Relative Path", + "icon": "$(copy)" + }, + { + "command": "commandtree.copyFullPath", + "title": "Copy Full Path", + "icon": "$(copy)" + }, + { + "command": "commandtree.makeExecutable", + "title": "Make Executable", + "icon": "$(unlock)" + }, { "command": "commandtree.generateSummaries", "title": "Generate AI Summaries", @@ -206,6 +221,21 @@ "when": "view == commandtree && viewItem == task", "group": "3_tagging@2" }, + { + "command": "commandtree.copyRelativePath", + "when": "view == commandtree && viewItem =~ /task.*/", + "group": "4_copy@1" + }, + { + "command": "commandtree.copyFullPath", + "when": "view == commandtree && viewItem =~ /task.*/", + "group": "4_copy@2" + }, + { + "command": "commandtree.makeExecutable", + "when": "view == commandtree && viewItem =~ /task.*/ && (isMac || isLinux)", + "group": "5_permissions@1" + }, { "command": "commandtree.run", "when": "view == commandtree && viewItem == task-quick", @@ -247,34 +277,19 @@ "group": "3_tagging@2" }, { - "command": "commandtree.run", - "when": "view == commandtree-quick && viewItem == task", - "group": "inline@1" - }, - { - "command": "commandtree.runInCurrentTerminal", - "when": "view == commandtree-quick && viewItem == task", - "group": "inline@3" - }, - { - "command": "commandtree.removeFromQuick", - "when": "view == commandtree-quick && viewItem == task", - "group": "inline@4" + "command": "commandtree.copyRelativePath", + "when": "view == commandtree-quick && viewItem =~ /task.*/", + "group": "4_copy@1" }, { - "command": "commandtree.run", - "when": "view == commandtree-quick && viewItem == task", - "group": "1_run@1" + "command": "commandtree.copyFullPath", + "when": "view == commandtree-quick && viewItem =~ /task.*/", + "group": "4_copy@2" }, { - "command": "commandtree.runInCurrentTerminal", - "when": "view == commandtree-quick && viewItem == task", - "group": "1_run@2" - }, - { - "command": "commandtree.removeFromQuick", - "when": "view == commandtree-quick && viewItem == task", - "group": "2_tags@1" + "command": "commandtree.makeExecutable", + "when": "view == commandtree-quick && viewItem =~ /task.*/ && (isMac || isLinux)", + "group": "5_permissions@1" }, { "command": "commandtree.run", @@ -379,7 +394,7 @@ "format:check": "prettier --check \"src/**/*.ts\"", "pretest": "npm run compile", "test": "npm run test:unit && npm run test:e2e", - "test:unit": "mocha out/test/unit/**/*.test.js", + "test:unit": "mocha --bail out/test/unit/**/*.test.js", "test:e2e": "vscode-test", "test:coverage": "vscode-test --coverage", "coverage:check": "node tools/check-coverage.mjs", @@ -408,7 +423,26 @@ "typescript-eslint": "^8.57.2" }, "overrides": { - "glob": "^13.0.6" + "glob": "^13.0.6", + "@secretlint/config-loader": { + "ajv": "^8.18.0" + }, + "table": { + "ajv": "^8.18.0" + }, + "@vscode/vsce": { + "minimatch": "^3.1.5" + }, + "diff": "^9.0.0", + "lodash": "^4.18.1", + "markdown-it": "^14.1.1", + "minimatch": "^10.2.5", + "picomatch": "^4.0.4", + "qs": "^6.15.1", + "serialize-javascript": "^7.0.5", + "underscore": "^1.13.8", + "undici": "^7.25.0", + "uuid": "^14.0.0" }, "dependencies": { "node-sqlite3-wasm": "^0.8.55" diff --git a/src/CommandTreeProvider.ts b/src/CommandTreeProvider.ts index e7045ae..286981e 100644 --- a/src/CommandTreeProvider.ts +++ b/src/CommandTreeProvider.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { isPhonyTask, isPrivateTask } from "./models/TaskItem"; +import { isCommandItem, isPrivateTask } from "./models/TaskItem"; import type { CommandItem, CategoryDef } from "./models/TaskItem"; import type { CommandTreeItem } from "./models/TaskItem"; import type { DiscoveryResult } from "./discovery"; @@ -13,13 +13,38 @@ import type { CommandRow } from "./db/db"; import { getDbOrThrow } from "./db/lifecycle"; type SortOrder = "folder" | "name" | "type"; +const SCRIPT_URI_LIST_MIME = "text/uri-list"; +const SCRIPT_PLAIN_TEXT_MIME = "text/plain"; +const SCRIPT_COMMAND_MIME = "application/vnd.commandtree.script"; +const URI_LIST_SEPARATOR = "\r\n"; +const PLAIN_TEXT_SEPARATOR = "\n"; + +function buildUriList(tasks: readonly CommandItem[]): string { + return tasks.map((task) => vscode.Uri.file(task.filePath).toString()).join(URI_LIST_SEPARATOR); +} + +function buildPlainPathList(tasks: readonly CommandItem[]): string { + return tasks.map((task) => task.filePath).join(PLAIN_TEXT_SEPARATOR); +} + +function buildCommandIdList(tasks: readonly CommandItem[]): string { + return tasks.map((task) => task.id).join(PLAIN_TEXT_SEPARATOR); +} /** * Tree data provider for CommandTree view. */ -export class CommandTreeProvider implements vscode.TreeDataProvider<CommandTreeItem> { +export class CommandTreeProvider + implements vscode.TreeDataProvider<CommandTreeItem>, vscode.TreeDragAndDropController<CommandTreeItem> +{ private readonly _onDidChangeTreeData = new vscode.EventEmitter<CommandTreeItem | undefined>(); public readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + public readonly dropMimeTypes: readonly string[] = []; + public readonly dragMimeTypes: readonly string[] = [ + SCRIPT_URI_LIST_MIME, + SCRIPT_PLAIN_TEXT_MIME, + SCRIPT_COMMAND_MIME, + ]; private commands: CommandItem[] = []; private discoveryResult: DiscoveryResult | null = null; @@ -27,6 +52,7 @@ export class CommandTreeProvider implements vscode.TreeDataProvider<CommandTreeI private summaries: ReadonlyMap<string, CommandRow> = new Map(); private readonly tagConfig: TagConfig; private readonly workspaceRoot: string; + private refreshPromise: Promise<void> | null = null; public constructor(workspaceRoot: string) { this.workspaceRoot = workspaceRoot; @@ -34,6 +60,20 @@ export class CommandTreeProvider implements vscode.TreeDataProvider<CommandTreeI } public async refresh(): Promise<void> { + if (this.refreshPromise !== null) { + logger.info("CommandTreeProvider.refresh() sharing in-flight refresh"); + await this.refreshPromise; + return; + } + this.refreshPromise = this.runRefresh(); + try { + await this.refreshPromise; + } finally { + this.refreshPromise = null; + } + } + + private async runRefresh(): Promise<void> { logger.info("CommandTreeProvider.refresh() starting"); this.tagConfig.load(); logger.info("Tag config loaded, getting exclude patterns"); @@ -123,6 +163,16 @@ export class CommandTreeProvider implements vscode.TreeDataProvider<CommandTreeI return element; } + public handleDrag(source: readonly CommandTreeItem[], dataTransfer: vscode.DataTransfer): void { + const tasks = source.map((item) => item.data).filter(isCommandItem); + if (tasks.length === 0) { + return; + } + dataTransfer.set(SCRIPT_URI_LIST_MIME, new vscode.DataTransferItem(buildUriList(tasks))); + dataTransfer.set(SCRIPT_PLAIN_TEXT_MIME, new vscode.DataTransferItem(buildPlainPathList(tasks))); + dataTransfer.set(SCRIPT_COMMAND_MIME, new vscode.DataTransferItem(buildCommandIdList(tasks))); + } + public async getChildren(element?: CommandTreeItem): Promise<CommandTreeItem[]> { if (!this.discoveryResult) { logger.info("getChildren: no discovery result yet, triggering refresh"); @@ -188,41 +238,25 @@ export class CommandTreeProvider implements vscode.TreeDataProvider<CommandTreeI return Number(isPrivateTask(a)) - Number(isPrivateTask(b)); } - private compareHelpTasks(a: CommandItem, b: CommandItem): number { - const isHelpA = a.type === "make" && a.label === "help"; - const isHelpB = b.type === "make" && b.label === "help"; - return Number(isHelpB) - Number(isHelpA); - } - - private comparePhonyTasks(a: CommandItem, b: CommandItem): number { - return Number(isPhonyTask(b)) - Number(isPhonyTask(a)); - } - - private compareMakeTaskPriority(a: CommandItem, b: CommandItem): number { - if (a.type !== "make" || b.type !== "make") { - return 0; - } - return this.compareHelpTasks(a, b) || this.comparePhonyTasks(a, b); + private compareLabels(a: CommandItem, b: CommandItem): number { + return a.label.localeCompare(b.label, undefined, { sensitivity: "base" }); } private getComparator(): (a: CommandItem, b: CommandItem) => number { const order = this.getSortOrder(); if (order === "folder") { return (a, b) => - a.category.localeCompare(b.category) || + a.category.localeCompare(b.category, undefined, { sensitivity: "base" }) || this.comparePrivateTasks(a, b) || - this.compareMakeTaskPriority(a, b) || - a.label.localeCompare(b.label); + this.compareLabels(a, b); } if (order === "type") { return (a, b) => - a.type.localeCompare(b.type) || + a.type.localeCompare(b.type, undefined, { sensitivity: "base" }) || this.comparePrivateTasks(a, b) || - this.compareMakeTaskPriority(a, b) || - a.label.localeCompare(b.label); + this.compareLabels(a, b); } - return (a, b) => - this.comparePrivateTasks(a, b) || this.compareMakeTaskPriority(a, b) || a.label.localeCompare(b.label); + return (a, b) => this.comparePrivateTasks(a, b) || this.compareLabels(a, b); } private applyTagFilter(tasks: CommandItem[]): CommandItem[] { diff --git a/src/db/lifecycle.ts b/src/db/lifecycle.ts index d20e6e3..2549ef6 100644 --- a/src/db/lifecycle.ts +++ b/src/db/lifecycle.ts @@ -1,6 +1,6 @@ /** * SPEC: database-schema, DB-LOCK-RECOVERY - * Singleton lifecycle management for the database. + * Lifecycle management for the database. State lives in appState. */ import * as fs from "fs"; @@ -10,27 +10,23 @@ import type { DbHandle } from "./db"; import { openDatabase, initSchema, closeDatabase } from "./db"; import type { Result } from "../models/Result"; import { ok, err } from "../models/Result"; +import { isLockError, removeLockFiles as removeLockFilesPure } from "./lockArtifacts"; +import { appState } from "../state"; 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. + * Initialises the SQLite database. * If the database is locked, retries for 10 seconds then * forcefully removes lock/journal files and retries. */ export async function initDb(workspaceRoot: string): Promise<Result<DbHandle, string>> { - if (dbHandle !== null && fs.existsSync(dbHandle.path)) { - return ok(dbHandle); + if (appState.dbHandle !== null && fs.existsSync(appState.dbHandle.path)) { + return ok(appState.dbHandle); } resetStaleHandle(); @@ -63,8 +59,8 @@ export async function initDb(workspaceRoot: string): Promise<Result<DbHandle, st * Returns error if the database has not been initialised. */ export function getDb(): Result<DbHandle, string> { - if (dbHandle !== null && fs.existsSync(dbHandle.path)) { - return ok(dbHandle); + if (appState.dbHandle !== null && fs.existsSync(appState.dbHandle.path)) { + return ok(appState.dbHandle); } resetStaleHandle(); return err("Database not initialised. Call initDb first."); @@ -83,9 +79,9 @@ export function getDbOrThrow(): DbHandle { } function resetStaleHandle(): void { - if (dbHandle !== null) { - closeDatabase(dbHandle); - dbHandle = null; + if (appState.dbHandle !== null) { + closeDatabase(appState.dbHandle); + appState.dbHandle = null; } } @@ -93,8 +89,8 @@ function resetStaleHandle(): void { * Disposes the database connection. */ export function disposeDb(): void { - const currentDb = dbHandle; - dbHandle = null; + const currentDb = appState.dbHandle; + appState.dbHandle = null; if (currentDb !== null) { closeDatabase(currentDb); } @@ -113,15 +109,11 @@ function tryOpenAndInit(dbPath: string): Result<DbHandle, string> { const msg = e instanceof Error ? e.message : String(e); return err(msg); } - dbHandle = openResult.value; + appState.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"; @@ -143,37 +135,14 @@ async function retryWithBackoff(dbPath: string): Promise<Result<DbHandle, string /** * SPEC: DB-LOCK-RECOVERY - * Forcefully removes SQLite lock artifacts: - * - .lock directory - * - -journal file - * - -wal file - * - -shm file + * Forcefully removes SQLite lock artifacts. Logging wrapper around the pure helper. */ 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), - }); - } - } + removeLockFilesPure(dbPath, { + onRemoved: (artifactPath) => logger.info("Removed lock artifact", { path: artifactPath }), + onError: (artifactPath, message) => + logger.error("Failed to remove lock artifact", { path: artifactPath, error: message }), + }); } async function sleep(ms: number): Promise<void> { @@ -184,5 +153,5 @@ async function sleep(ms: number): Promise<void> { // Test-only: reset internal state export function resetForTesting(): void { - dbHandle = null; + appState.dbHandle = null; } diff --git a/src/db/lockArtifacts.ts b/src/db/lockArtifacts.ts new file mode 100644 index 0000000..3d32fd0 --- /dev/null +++ b/src/db/lockArtifacts.ts @@ -0,0 +1,58 @@ +/** + * SPEC: DB-LOCK-RECOVERY + * Pure filesystem helpers for detecting and clearing SQLite lock artifacts. + * No VS Code dependency — consumed by both lifecycle.ts and unit tests. + */ + +import * as fs from "fs"; + +const JOURNAL_SUFFIX = "-journal"; +const WAL_SUFFIX = "-wal"; +const SHM_SUFFIX = "-shm"; +const LOCK_DIR_SUFFIX = ".lock"; + +export interface LockArtifact { + readonly path: string; + readonly isDir: boolean; +} + +export function isLockError(message: string): boolean { + return message.includes("locked") || message.includes("SQLITE_BUSY"); +} + +export function lockArtifactsFor(dbPath: string): readonly LockArtifact[] { + return [ + { 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 }, + ]; +} + +export interface RemoveLockFilesOptions { + readonly onRemoved?: (artifactPath: string) => void; + readonly onError?: (artifactPath: string, message: string) => void; +} + +/** + * SPEC: DB-LOCK-RECOVERY + * Removes SQLite lock artifacts: .lock directory, -journal/-wal/-shm files. + * Silently continues on missing artifacts. Reports per-artifact outcome via callbacks. + */ +export function removeLockFiles(dbPath: string, options: RemoveLockFilesOptions = {}): void { + for (const target of lockArtifactsFor(dbPath)) { + if (!fs.existsSync(target.path)) { + continue; + } + try { + if (target.isDir) { + fs.rmSync(target.path, { recursive: true }); + } else { + fs.unlinkSync(target.path); + } + options.onRemoved?.(target.path); + } catch (e: unknown) { + options.onError?.(target.path, e instanceof Error ? e.message : String(e)); + } + } +} diff --git a/src/discovery/cargo.ts b/src/discovery/cargo.ts index 905b53f..4fcf727 100644 --- a/src/discovery/cargo.ts +++ b/src/discovery/cargo.ts @@ -109,7 +109,7 @@ function parseCargoBinaries(content: string): string[] { let match; while ((match = binRegex.exec(content)) !== null) { const name = match[1]; - if (name !== undefined && name !== "" && !binaries.includes(name)) { + if (name !== undefined && !binaries.includes(name)) { binaries.push(name); } } @@ -128,7 +128,7 @@ function parseCargoExamples(content: string): string[] { let match; while ((match = exampleRegex.exec(content)) !== null) { const name = match[1]; - if (name !== undefined && name !== "" && !examples.includes(name)) { + if (name !== undefined && !examples.includes(name)) { examples.push(name); } } diff --git a/src/discovery/composer.ts b/src/discovery/composer.ts index 329a28b..fc32c36 100644 --- a/src/discovery/composer.ts +++ b/src/discovery/composer.ts @@ -72,7 +72,7 @@ function buildCommandItem(params: BuildCommandItemParams): CommandItem { async function extractScriptsFromFile(file: vscode.Uri, workspaceRoot: string): Promise<CommandItem[]> { const content = await readFileContent(file); const composer = JSON.parse(content) as ComposerJson; - if (composer.scripts === undefined || typeof composer.scripts !== "object") { + if (composer.scripts === undefined) { return []; } diff --git a/src/discovery/deno.ts b/src/discovery/deno.ts index 2a86932..3c4c826 100644 --- a/src/discovery/deno.ts +++ b/src/discovery/deno.ts @@ -42,7 +42,7 @@ export async function discoverDenoTasks(workspaceRoot: string, excludePatterns: const content = await readFileContent(file); const cleanJson = removeJsonComments(content); const deno = JSON.parse(cleanJson) as DenoJson; - if (deno.tasks === undefined || typeof deno.tasks !== "object") { + if (deno.tasks === undefined) { continue; } @@ -50,10 +50,6 @@ export async function discoverDenoTasks(workspaceRoot: string, excludePatterns: const category = simplifyPath(file.fsPath, workspaceRoot); for (const [name, command] of Object.entries(deno.tasks)) { - if (typeof command !== "string") { - continue; - } - const task: MutableCommandItem = { id: generateCommandId("deno", file.fsPath, name), label: name, diff --git a/src/discovery/docker.ts b/src/discovery/docker.ts index e01a01a..df91420 100644 --- a/src/discovery/docker.ts +++ b/src/discovery/docker.ts @@ -1,9 +1,15 @@ import * as vscode from "vscode"; import * as path from "path"; -import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; +import type { CommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; import { readFileContent } from "../utils/fileUtils"; +const DOCKERFILE_BUILD_LABEL = "build Dockerfile"; +const DOCKERFILE_BUILD_DESCRIPTION = "Build Docker image"; +const COMPOSE_FILE_GLOBS = ["**/docker-compose.yml", "**/docker-compose.yaml", "**/compose.yml", "**/compose.yaml"]; +const DOCKERFILE_GLOBS = ["**/Dockerfile", "**/Dockerfile.*"]; +const QUOTE_CHAR = '"'; + export const ICON_DEF: IconDef = { icon: "server-environment", color: "terminal.ansiBlue", @@ -14,94 +20,121 @@ export const CATEGORY_DEF: CategoryDef = { }; /** - * Discovers Docker Compose services from docker-compose.yml files. + * Discovers executable Docker Compose and Dockerfile commands. */ export async function discoverDockerComposeServices( workspaceRoot: string, excludePatterns: string[] ): Promise<CommandItem[]> { const exclude = `{${excludePatterns.join(",")}}`; - const [yml, yaml, composeYml, composeYaml] = await Promise.all([ - vscode.workspace.findFiles("**/docker-compose.yml", exclude), - vscode.workspace.findFiles("**/docker-compose.yaml", exclude), - vscode.workspace.findFiles("**/compose.yml", exclude), - vscode.workspace.findFiles("**/compose.yaml", exclude), + const [composeFiles, dockerFiles] = await Promise.all([ + findFiles(COMPOSE_FILE_GLOBS, exclude), + findFiles(DOCKERFILE_GLOBS, exclude), ]); - const allFiles = [...yml, ...yaml, ...composeYml, ...composeYaml]; - const commands: CommandItem[] = []; - - for (const file of allFiles) { - const content = await readFileContent(file); - const dockerDir = path.dirname(file.fsPath); - const category = simplifyPath(file.fsPath, workspaceRoot); - const services = parseDockerComposeServices(content); - - // Add general compose commands - const generalCommands = [ - { - name: "up", - command: "docker compose up", - description: "Start all services", - }, - { - name: "up -d", - command: "docker compose up -d", - description: "Start in background", - }, - { - name: "down", - command: "docker compose down", - description: "Stop all services", - }, - { - name: "build", - command: "docker compose build", - description: "Build all services", - }, - { - name: "logs", - command: "docker compose logs -f", - description: "View logs", - }, - { - name: "ps", - command: "docker compose ps", - description: "List containers", - }, - ]; - - for (const cmd of generalCommands) { - commands.push({ - id: generateCommandId("docker", file.fsPath, cmd.name), - label: cmd.name, - type: "docker", - category, - command: cmd.command, - cwd: dockerDir, - filePath: file.fsPath, - tags: [], - description: cmd.description, - }); - } + const composeCommands = await discoverComposeCommands({ files: composeFiles, workspaceRoot }); + const dockerfileCommands = dockerFiles.map((file) => buildDockerfileTask(file, workspaceRoot)); + return [...composeCommands, ...dockerfileCommands]; +} - // Add per-service commands - for (const service of services) { - const task: MutableCommandItem = { - id: generateCommandId("docker", file.fsPath, `up-${service}`), - label: `up ${service}`, - type: "docker", - category, - command: `docker compose up ${service}`, - cwd: dockerDir, - filePath: file.fsPath, - tags: [], - description: `Start ${service} service`, - }; - commands.push(task); - } - } +interface ComposeCommandDef { + readonly name: string; + readonly args: string; + readonly description: string; +} + +const GENERAL_COMPOSE_COMMANDS: readonly ComposeCommandDef[] = [ + { name: "up", args: "up", description: "Start all services" }, + { name: "up -d", args: "up -d", description: "Start in background" }, + { name: "down", args: "down", description: "Stop all services" }, + { name: "build", args: "build", description: "Build all services" }, + { name: "logs", args: "logs -f", description: "View logs" }, + { name: "ps", args: "ps", description: "List containers" }, +]; + +async function findFiles(globs: readonly string[], exclude: string): Promise<vscode.Uri[]> { + const groups = await Promise.all(globs.map(async (glob) => await vscode.workspace.findFiles(glob, exclude))); + return groups.flat(); +} + +async function discoverComposeCommands(params: { + readonly files: readonly vscode.Uri[]; + readonly workspaceRoot: string; +}): Promise<CommandItem[]> { + const groups = await Promise.all( + params.files.map(async (file) => await buildComposeTasks(file, params.workspaceRoot)) + ); + return groups.flat(); +} + +async function buildComposeTasks(file: vscode.Uri, workspaceRoot: string): Promise<CommandItem[]> { + const content = await readFileContent(file); + const services = parseDockerComposeServices(content); + const params = createDockerTaskParams(file, workspaceRoot); + return [...buildGeneralComposeTasks(params), ...buildServiceComposeTasks({ ...params, services })]; +} + +function createDockerTaskParams(file: vscode.Uri, workspaceRoot: string): DockerTaskParams { + return { + filePath: file.fsPath, + dockerDir: path.dirname(file.fsPath), + category: simplifyPath(file.fsPath, workspaceRoot), + }; +} + +interface DockerTaskParams { + readonly filePath: string; + readonly dockerDir: string; + readonly category: string; +} + +function buildGeneralComposeTasks(params: DockerTaskParams): CommandItem[] { + return GENERAL_COMPOSE_COMMANDS.map((def) => + buildComposeTask({ ...params, name: def.name, args: def.args, description: def.description }) + ); +} + +function buildServiceComposeTasks(params: DockerTaskParams & { readonly services: readonly string[] }): CommandItem[] { + return params.services.map((service) => + buildComposeTask({ + ...params, + name: `up ${service}`, + args: `up ${service}`, + description: `Start ${service} service`, + }) + ); +} + +function buildComposeTask(params: DockerTaskParams & ComposeCommandDef): CommandItem { + return { + id: generateCommandId("docker", params.filePath, params.name), + label: params.name, + type: "docker", + category: params.category, + command: `docker compose -f ${quotePath(params.filePath)} ${params.args}`, + cwd: params.dockerDir, + filePath: params.filePath, + tags: [], + description: params.description, + }; +} + +function buildDockerfileTask(file: vscode.Uri, workspaceRoot: string): CommandItem { + const params = createDockerTaskParams(file, workspaceRoot); + return { + id: generateCommandId("docker", params.filePath, DOCKERFILE_BUILD_LABEL), + label: DOCKERFILE_BUILD_LABEL, + type: "docker", + category: params.category, + command: `docker build -f ${quotePath(params.filePath)} .`, + cwd: params.dockerDir, + filePath: params.filePath, + tags: [], + description: DOCKERFILE_BUILD_DESCRIPTION, + }; +} - return commands; +function quotePath(filePath: string): string { + return `${QUOTE_CHAR}${filePath.replaceAll(QUOTE_CHAR, `\\${QUOTE_CHAR}`)}${QUOTE_CHAR}`; } /** Counts leading spaces in a line. */ diff --git a/src/discovery/gradle.ts b/src/discovery/gradle.ts index c2ada68..3b6d1a3 100644 --- a/src/discovery/gradle.ts +++ b/src/discovery/gradle.ts @@ -80,7 +80,7 @@ function parseGradleTasks(content: string): string[] { let match; while ((match = taskDefRegex.exec(content)) !== null) { const task = match[1]; - if (task !== undefined && task !== "" && !tasks.includes(task)) { + if (task !== undefined && !tasks.includes(task)) { tasks.push(task); } } @@ -89,7 +89,7 @@ function parseGradleTasks(content: string): string[] { const kotlinTaskRegex = /tasks\.(register|create)\s*\(\s*["'](\w+)["']/g; while ((match = kotlinTaskRegex.exec(content)) !== null) { const task = match[2]; - if (task !== undefined && task !== "" && !tasks.includes(task)) { + if (task !== undefined && !tasks.includes(task)) { tasks.push(task); } } diff --git a/src/discovery/make.ts b/src/discovery/make.ts index 652185c..f0cb77b 100644 --- a/src/discovery/make.ts +++ b/src/discovery/make.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import * as path from "path"; -import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; +import type { CommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; import { readFileContent } from "../utils/fileUtils"; @@ -28,18 +28,12 @@ export async function discoverMakeTargets(workspaceRoot: string, excludePatterns for (const file of allFiles) { const content = await readFileContent(file); - const phonyTargets = parsePhonyTargets(content); const targets = parseMakeTargets(content); const makeDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); for (const { name, line } of targets) { - // Skip internal targets (start with .) - if (name.startsWith(".")) { - continue; - } - - const command: MutableCommandItem = { + commands.push({ id: generateCommandId("make", file.fsPath, name), label: name, type: "make", @@ -49,13 +43,7 @@ export async function discoverMakeTargets(workspaceRoot: string, excludePatterns filePath: file.fsPath, tags: [], line, - }; - - if (phonyTargets.has(name)) { - command.isPhony = true; - } - - commands.push(command); + }); } } @@ -67,54 +55,6 @@ interface MakeTarget { readonly line: number; } -function addPhonyTargets(line: string, phonyTargets: Set<string>): void { - for (const name of line.split(/\s+/)) { - if (name !== "") { - phonyTargets.add(name); - } - } -} - -function trimContinuation(line: string): string { - return line.endsWith("\\") ? line.slice(0, -1).trim() : line; -} - -function isContinuationLine(line: string): boolean { - return line.endsWith("\\"); -} - -function readPhonyLine(line: string): string | undefined { - const trimmed = line.trim(); - if (!trimmed.startsWith(".PHONY:")) { - return undefined; - } - return trimmed.slice(".PHONY:".length).trim(); -} - -function parsePhonyTargets(content: string): ReadonlySet<string> { - const phonyTargets = new Set<string>(); - let collecting = false; - - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (collecting) { - addPhonyTargets(trimContinuation(trimmed), phonyTargets); - collecting = isContinuationLine(trimmed); - continue; - } - - const phonyLine = readPhonyLine(line); - if (phonyLine === undefined) { - continue; - } - - addPhonyTargets(trimContinuation(phonyLine), phonyTargets); - collecting = isContinuationLine(phonyLine); - } - - return phonyTargets; -} - /** * Parses Makefile to extract target names and their line numbers. */ @@ -127,8 +67,8 @@ function parseMakeTargets(content: string): MakeTarget[] { let match; while ((match = targetRegex.exec(content)) !== null) { - const name = match[1]; - if (name === undefined || name === "" || seen.has(name)) { + const name = match[1] ?? ""; + if (seen.has(name)) { continue; } seen.add(name); diff --git a/src/discovery/markdown.ts b/src/discovery/markdown.ts index f586da7..5ed3003 100644 --- a/src/discovery/markdown.ts +++ b/src/discovery/markdown.ts @@ -3,6 +3,7 @@ import * as path from "path"; import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; import { readFileContent } from "../utils/fileUtils"; +import { extractDescription } from "./parsers/markdownParser"; export const ICON_DEF: IconDef = { icon: "markdown", @@ -13,8 +14,6 @@ export const CATEGORY_DEF: CategoryDef = { label: "Markdown Files", }; -const MAX_DESCRIPTION_LENGTH = 150; - /** * Discovers Markdown files (.md) in the workspace. */ @@ -48,40 +47,3 @@ export async function discoverMarkdownFiles(workspaceRoot: string, excludePatter return commands; } - -/** - * Extracts a description from the markdown content. - * Uses the first heading or first paragraph. - */ -function extractDescription(content: string): string | undefined { - const lines = content.split("\n"); - - for (const line of lines) { - const trimmed = line.trim(); - - if (trimmed === "") { - continue; - } - - if (trimmed.startsWith("#")) { - const heading = trimmed.replace(/^#+\s*/, "").trim(); - if (heading !== "") { - return truncate(heading); - } - continue; - } - - if (!trimmed.startsWith("```") && !trimmed.startsWith("---")) { - return truncate(trimmed); - } - } - - return undefined; -} - -function truncate(text: string): string { - if (text.length <= MAX_DESCRIPTION_LENGTH) { - return text; - } - return `${text.substring(0, MAX_DESCRIPTION_LENGTH)}...`; -} diff --git a/src/discovery/mise.ts b/src/discovery/mise.ts index 14601d1..fb23266 100644 --- a/src/discovery/mise.ts +++ b/src/discovery/mise.ts @@ -55,10 +55,6 @@ export async function discoverMiseTasks(workspaceRoot: string, excludePatterns: tags: [], }; - if (task.params.length > 0) { - taskCommand.params = task.params; - } - if (task.description !== undefined) { taskCommand.description = task.description; } diff --git a/src/discovery/npm.ts b/src/discovery/npm.ts index 3df9bde..900fcce 100644 --- a/src/discovery/npm.ts +++ b/src/discovery/npm.ts @@ -27,7 +27,7 @@ export async function discoverNpmScripts(workspaceRoot: string, excludePatterns: for (const file of files) { const content = await readFileContent(file); const pkg = JSON.parse(content) as PackageJson; - if (pkg.scripts === undefined || typeof pkg.scripts !== "object") { + if (pkg.scripts === undefined) { continue; } @@ -35,10 +35,6 @@ export async function discoverNpmScripts(workspaceRoot: string, excludePatterns: const category = simplifyPath(file.fsPath, workspaceRoot); for (const [name, command] of Object.entries(pkg.scripts)) { - if (typeof command !== "string") { - continue; - } - commands.push({ id: generateCommandId("npm", file.fsPath, name), label: name, diff --git a/src/discovery/parsers/markdownParser.ts b/src/discovery/parsers/markdownParser.ts new file mode 100644 index 0000000..6b33bae --- /dev/null +++ b/src/discovery/parsers/markdownParser.ts @@ -0,0 +1,39 @@ +const MAX_DESCRIPTION_LENGTH = 150; +const HEADING_MARKER_REGEX = /^#+\s*/; + +function truncate(text: string): string { + if (text.length <= MAX_DESCRIPTION_LENGTH) { + return text; + } + return `${text.substring(0, MAX_DESCRIPTION_LENGTH)}...`; +} + +/** + * Extracts a description from markdown content. + * Uses the first heading or first paragraph, truncated to MAX_DESCRIPTION_LENGTH. + */ +export function extractDescription(content: string): string | undefined { + const lines = content.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed === "") { + continue; + } + + if (trimmed.startsWith("#")) { + const heading = trimmed.replace(HEADING_MARKER_REGEX, "").trim(); + if (heading !== "") { + return truncate(heading); + } + continue; + } + + if (!trimmed.startsWith("```") && !trimmed.startsWith("---")) { + return truncate(trimmed); + } + } + + return undefined; +} diff --git a/src/extension.ts b/src/extension.ts index 8888d5e..9161264 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,45 +1,101 @@ import * as vscode from "vscode"; +import * as path from "path"; +import * as fs from "fs/promises"; import { CommandTreeProvider } from "./CommandTreeProvider"; -import { CommandTreeItem, isCommandItem } from "./models/TaskItem"; -import type { CommandItem } from "./models/TaskItem"; +import { isCommandItem } from "./models/TaskItem"; +import type { CommandTreeItem, CommandItem } from "./models/TaskItem"; +import type { Result } from "./models/Result"; +import { err, ok } from "./models/Result"; import { TaskRunner } from "./runners/TaskRunner"; import { QuickTasksProvider } from "./QuickTasksProvider"; import { logger } from "./utils/logger"; import { initDb, disposeDb } from "./db/lifecycle"; -import { summariseAllTasks, registerAllCommands } from "./semantic/summaryPipeline"; -import { createVSCodeFileSystem } from "./semantic/vscodeAdapters"; import { forceSelectModel } from "./semantic/summariser"; import { syncTagsFromConfig } from "./tags/tagSync"; import { setupFileWatchers } from "./watchers"; import { PrivateTaskDecorationProvider } from "./tree/PrivateTaskDecorationProvider"; +import { appState } from "./state"; +import { + initAiSummaries, + registerDiscoveredCommands, + runSummarisation, + syncAndSummarise, +} from "./summaryOrchestration"; +import type { SummaryDeps } from "./summaryOrchestration"; -let treeProvider: CommandTreeProvider; -let quickTasksProvider: QuickTasksProvider; -let taskRunner: TaskRunner; +const MAKE_EXECUTABLE_COMMAND = "commandtree.makeExecutable"; +const EXECUTE_PERMISSION_BITS = 0o111; +const WINDOWS_PLATFORM = "win32"; export interface ExtensionExports { commandTreeProvider: CommandTreeProvider; quickTasksProvider: QuickTasksProvider; } +function getTreeProvider(): CommandTreeProvider { + if (appState.treeProvider === undefined) { + throw new Error("CommandTree extension not activated"); + } + return appState.treeProvider; +} + +function getQuickTasksProvider(): QuickTasksProvider { + if (appState.quickTasksProvider === undefined) { + throw new Error("CommandTree extension not activated"); + } + return appState.quickTasksProvider; +} + +function getTaskRunner(): TaskRunner { + if (appState.taskRunner === undefined) { + throw new Error("CommandTree extension not activated"); + } + return appState.taskRunner; +} + +function getSummaryDeps(workspaceRoot: string): SummaryDeps { + return { + workspaceRoot, + treeProvider: getTreeProvider(), + quickTasksProvider: getQuickTasksProvider(), + }; +} + export async function activate(context: vscode.ExtensionContext): Promise<ExtensionExports | undefined> { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; logger.info("Extension activating", { workspaceRoot }); - if (workspaceRoot === undefined || workspaceRoot === "") { + if (workspaceRoot === undefined) { logger.warn("No workspace root found, extension not activating"); return undefined; } - await initDatabaseSafe(workspaceRoot); - treeProvider = new CommandTreeProvider(workspaceRoot); - quickTasksProvider = new QuickTasksProvider(); - taskRunner = new TaskRunner(); + if (appState.activated && appState.treeProvider !== undefined && appState.quickTasksProvider !== undefined) { + logger.info("Extension already activated; reusing existing state"); + return { commandTreeProvider: appState.treeProvider, quickTasksProvider: appState.quickTasksProvider }; + } + appState.treeProvider = new CommandTreeProvider(workspaceRoot); + appState.quickTasksProvider = new QuickTasksProvider(); + appState.taskRunner = new TaskRunner(); + appState.activated = true; + context.subscriptions.push({ dispose: deactivate }); registerTreeViews(context); registerCommands(context); setupWatchers(context, workspaceRoot); - await initialDiscovery(workspaceRoot); - initAiSummaries(workspaceRoot); + await initDatabaseSafe(workspaceRoot); + runBackgroundStartup(workspaceRoot); logger.info("Extension activation complete"); - return { commandTreeProvider: treeProvider, quickTasksProvider }; + return { commandTreeProvider: appState.treeProvider, quickTasksProvider: appState.quickTasksProvider }; +} + +function runBackgroundStartup(workspaceRoot: string): void { + initialDiscovery(workspaceRoot) + .then(() => { + initAiSummaries(getSummaryDeps(workspaceRoot)); + }) + .catch((e: unknown) => { + logger.error("Initial discovery failed", { + error: e instanceof Error ? e.message : String(e), + }); + }); } async function initDatabaseSafe(workspaceRoot: string): Promise<void> { @@ -53,7 +109,7 @@ function setupWatchers(context: vscode.ExtensionContext, workspaceRoot: string): setupFileWatchers({ context, onTaskFileChange: () => { - syncAndSummarise(workspaceRoot).catch((e: unknown) => { + syncAndSummarise(getSummaryDeps(workspaceRoot)).catch((e: unknown) => { logger.error("Sync failed", { error: e instanceof Error ? e.message : "Unknown", }); @@ -71,21 +127,24 @@ function setupWatchers(context: vscode.ExtensionContext, workspaceRoot: string): async function initialDiscovery(workspaceRoot: string): Promise<void> { await syncQuickTasks(); - logger.info("syncQuickTasks complete", { taskCount: treeProvider.getAllTasks().length }); - await registerDiscoveredCommands(workspaceRoot); + logger.info("syncQuickTasks complete", { taskCount: getTreeProvider().getAllTasks().length }); + await registerDiscoveredCommands(getSummaryDeps(workspaceRoot)); await syncTagsFromJson(workspaceRoot); } function registerTreeViews(context: vscode.ExtensionContext): void { + const tp = getTreeProvider(); + const qp = getQuickTasksProvider(); context.subscriptions.push( vscode.window.createTreeView("commandtree", { - treeDataProvider: treeProvider, + treeDataProvider: tp, showCollapseAll: true, + dragAndDropController: tp, }), vscode.window.createTreeView("commandtree-quick", { - treeDataProvider: quickTasksProvider, + treeDataProvider: qp, showCollapseAll: true, - dragAndDropController: quickTasksProvider, + dragAndDropController: qp, }), vscode.window.registerFileDecorationProvider(new PrivateTaskDecorationProvider()) ); @@ -93,6 +152,8 @@ function registerTreeViews(context: vscode.ExtensionContext): void { function registerCommands(context: vscode.ExtensionContext): void { registerCoreCommands(context); + registerClipboardCommands(context); + registerFileCommands(context); registerFilterCommands(context); registerTagCommands(context); registerQuickCommands(context); @@ -101,18 +162,18 @@ function registerCommands(context: vscode.ExtensionContext): void { function registerCoreCommands(context: vscode.ExtensionContext): void { context.subscriptions.push( vscode.commands.registerCommand("commandtree.refresh", async () => { - await treeProvider.refresh(); - quickTasksProvider.updateTasks(treeProvider.getAllTasks()); + await getTreeProvider().refresh(); + getQuickTasksProvider().updateTasks(getTreeProvider().getAllTasks()); vscode.window.showInformationMessage("CommandTree refreshed"); }), vscode.commands.registerCommand("commandtree.run", async (item: CommandTreeItem | undefined) => { if (item !== undefined && isCommandItem(item.data)) { - await taskRunner.run(item.data, "newTerminal"); + await getTaskRunner().run(item.data, "newTerminal"); } }), vscode.commands.registerCommand("commandtree.runInCurrentTerminal", async (item: CommandTreeItem | undefined) => { if (item !== undefined && isCommandItem(item.data)) { - await taskRunner.run(item.data, "currentTerminal"); + await getTaskRunner().run(item.data, "currentTerminal"); } }), vscode.commands.registerCommand("commandtree.openPreview", async (item: CommandTreeItem | undefined) => { @@ -123,17 +184,28 @@ function registerCoreCommands(context: vscode.ExtensionContext): void { ); } +function registerClipboardCommands(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand("commandtree.copyRelativePath", handleCopyRelativePath), + vscode.commands.registerCommand("commandtree.copyFullPath", handleCopyFullPath) + ); +} + +function registerFileCommands(context: vscode.ExtensionContext): void { + context.subscriptions.push(vscode.commands.registerCommand(MAKE_EXECUTABLE_COMMAND, handleMakeExecutable)); +} + function registerFilterCommands(context: vscode.ExtensionContext): void { context.subscriptions.push( vscode.commands.registerCommand("commandtree.filterByTag", handleFilterByTag), vscode.commands.registerCommand("commandtree.clearFilter", () => { - treeProvider.clearFilters(); + getTreeProvider().clearFilters(); updateFilterContext(); }), vscode.commands.registerCommand("commandtree.generateSummaries", async () => { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (workspaceRoot !== undefined) { - await runSummarisation(workspaceRoot); + await runSummarisation(getSummaryDeps(workspaceRoot)); } }), vscode.commands.registerCommand("commandtree.selectModel", async () => { @@ -142,7 +214,7 @@ function registerFilterCommands(context: vscode.ExtensionContext): void { vscode.window.showInformationMessage(`CommandTree: AI model set to ${result.value}`); const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (workspaceRoot !== undefined) { - await runSummarisation(workspaceRoot); + await runSummarisation(getSummaryDeps(workspaceRoot)); } } else { vscode.window.showWarningMessage(`CommandTree: ${result.error}`); @@ -165,9 +237,9 @@ function registerQuickCommands(context: vscode.ExtensionContext): void { async (item: CommandTreeItem | CommandItem | undefined) => { const task = extractTask(item); if (task !== undefined) { - quickTasksProvider.addToQuick(task); - await treeProvider.refresh(); - quickTasksProvider.updateTasks(treeProvider.getAllTasks()); + getQuickTasksProvider().addToQuick(task); + await getTreeProvider().refresh(); + getQuickTasksProvider().updateTasks(getTreeProvider().getAllTasks()); } } ), @@ -176,20 +248,20 @@ function registerQuickCommands(context: vscode.ExtensionContext): void { async (item: CommandTreeItem | CommandItem | undefined) => { const task = extractTask(item); if (task !== undefined) { - quickTasksProvider.removeFromQuick(task); - await treeProvider.refresh(); - quickTasksProvider.updateTasks(treeProvider.getAllTasks()); + getQuickTasksProvider().removeFromQuick(task); + await getTreeProvider().refresh(); + getQuickTasksProvider().updateTasks(getTreeProvider().getAllTasks()); } } ), vscode.commands.registerCommand("commandtree.refreshQuick", () => { - quickTasksProvider.refresh(); + getQuickTasksProvider().refresh(); }) ); } async function handleFilterByTag(): Promise<void> { - const tags = treeProvider.getAllTags(); + const tags = getTreeProvider().getAllTags(); if (tags.length === 0) { await vscode.window.showInformationMessage("No tags defined. Right-click commands to add tags."); return; @@ -202,7 +274,7 @@ async function handleFilterByTag(): Promise<void> { placeHolder: "Select tag to filter by", }); if (selected) { - treeProvider.setTagFilter(selected.tag); + getTreeProvider().setTagFilter(selected.tag); updateFilterContext(); } } @@ -211,23 +283,64 @@ function extractTask(item: CommandTreeItem | CommandItem | undefined): CommandIt if (item === undefined) { return undefined; } - if (item instanceof CommandTreeItem) { + if ("data" in item) { return isCommandItem(item.data) ? item.data : undefined; } return item; } +async function handleCopyRelativePath(item: CommandTreeItem | CommandItem | undefined): Promise<void> { + const task = extractTask(item); + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (task === undefined || workspaceRoot === undefined) { + return; + } + await vscode.env.clipboard.writeText(path.relative(workspaceRoot, task.filePath)); +} + +async function handleCopyFullPath(item: CommandTreeItem | CommandItem | undefined): Promise<void> { + const task = extractTask(item); + if (task === undefined) { + return; + } + await vscode.env.clipboard.writeText(task.filePath); +} + +async function handleMakeExecutable(item: CommandTreeItem | CommandItem | undefined): Promise<void> { + const task = extractTask(item); + if (task === undefined || process.platform === WINDOWS_PLATFORM) { + return; + } + const result = await makeFileExecutable(task.filePath); + if (!result.ok) { + logger.error("Make executable failed", { error: result.error }); + vscode.window.showErrorMessage(`CommandTree: ${result.error}`); + return; + } + vscode.window.showInformationMessage(`CommandTree: Made ${path.basename(task.filePath)} executable`); +} + +async function makeFileExecutable(filePath: string): Promise<Result<void, string>> { + try { + const stat = await fs.stat(filePath); + await fs.chmod(filePath, stat.mode | EXECUTE_PERMISSION_BITS); + return ok(undefined); + } catch (e: unknown) { + return err(e instanceof Error ? e.message : "Unable to change file permissions"); + } +} + async function handleAddTag(item: CommandTreeItem | CommandItem | undefined, tagNameArg?: string): Promise<void> { const task = extractTask(item); if (task === undefined) { return; } - const tagName = tagNameArg ?? (await pickOrCreateTag(treeProvider.getAllTags(), task.label)); + const tagName = tagNameArg ?? (await pickOrCreateTag(getTreeProvider().getAllTags(), task.label)); if (tagName === undefined || tagName === "") { return; } - await treeProvider.addTaskToTag(task, tagName); - quickTasksProvider.updateTasks(treeProvider.getAllTasks()); + await getTreeProvider().addTaskToTag(task, tagName); + getQuickTasksProvider().updateTasks(getTreeProvider().getAllTasks()); } async function handleRemoveTag(item: CommandTreeItem | CommandItem | undefined, tagNameArg?: string): Promise<void> { @@ -250,22 +363,22 @@ async function handleRemoveTag(item: CommandTreeItem | CommandItem | undefined, } tagToRemove = selected.tag; } - await treeProvider.removeTaskFromTag(task, tagToRemove); - quickTasksProvider.updateTasks(treeProvider.getAllTasks()); + await getTreeProvider().removeTaskFromTag(task, tagToRemove); + getQuickTasksProvider().updateTasks(getTreeProvider().getAllTasks()); } async function syncQuickTasks(): Promise<void> { - await treeProvider.refresh(); - const allTasks = treeProvider.getAllTasks(); - quickTasksProvider.updateTasks(allTasks); + await getTreeProvider().refresh(); + const allTasks = getTreeProvider().getAllTasks(); + getQuickTasksProvider().updateTasks(allTasks); } async function syncTagsFromJson(workspaceRoot: string): Promise<void> { - const allTasks = treeProvider.getAllTasks(); + const allTasks = getTreeProvider().getAllTasks(); const synced = syncTagsFromConfig({ allTasks, workspaceRoot }); if (synced) { - await treeProvider.refresh(); - quickTasksProvider.updateTasks(treeProvider.getAllTasks()); + await getTreeProvider().refresh(); + getQuickTasksProvider().updateTasks(getTreeProvider().getAllTasks()); } } @@ -295,76 +408,11 @@ async function pickOrCreateTag(existingTags: string[], taskLabel: string): Promi }); } -async function registerDiscoveredCommands(workspaceRoot: string): Promise<void> { - const tasks = treeProvider.getAllTasks(); - if (tasks.length === 0) { - return; - } - const result = await registerAllCommands({ - tasks, - workspaceRoot, - fs: createVSCodeFileSystem(), - }); - if (!result.ok) { - logger.warn("Command registration failed", { error: result.error }); - } else { - logger.info("Commands registered in DB", { count: result.value }); - } -} - -function initAiSummaries(workspaceRoot: string): void { - const aiConfig = vscode.workspace.getConfiguration("commandtree").get<boolean>("enableAiSummaries"); - if (aiConfig === false) { - return; - } - vscode.commands.executeCommand("setContext", "commandtree.aiSummariesEnabled", true); - runSummarisation(workspaceRoot).catch((e: unknown) => { - logger.error("AI summarisation failed", { - error: e instanceof Error ? e.message : "Unknown", - }); - }); -} - -async function runSummarisation(workspaceRoot: string): Promise<void> { - const tasks = treeProvider.getAllTasks(); - logger.info("[SUMMARY] Starting", { taskCount: tasks.length }); - if (tasks.length === 0) { - logger.warn("[SUMMARY] No tasks to summarise"); - return; - } - const summaryResult = await summariseAllTasks({ - tasks, - workspaceRoot, - fs: createVSCodeFileSystem(), - onProgress: (done, total, label) => { - logger.info(`[SUMMARY] ${label}`, { done, total }); - }, - }); - if (!summaryResult.ok) { - logger.error("Summary pipeline failed", { error: summaryResult.error }); - vscode.window.showErrorMessage(`CommandTree: Summary failed — ${summaryResult.error}`); - return; - } - if (summaryResult.value > 0) { - await treeProvider.refresh(); - quickTasksProvider.updateTasks(treeProvider.getAllTasks()); - } - vscode.window.showInformationMessage(`CommandTree: Summarised ${summaryResult.value} commands`); -} - -async function syncAndSummarise(workspaceRoot: string): Promise<void> { - await syncQuickTasks(); - await registerDiscoveredCommands(workspaceRoot); - const aiConfig = vscode.workspace.getConfiguration("commandtree").get<boolean>("enableAiSummaries"); - if (aiConfig !== false) { - await runSummarisation(workspaceRoot); - } -} - function updateFilterContext(): void { - vscode.commands.executeCommand("setContext", "commandtree.hasFilter", treeProvider.hasFilter()); + vscode.commands.executeCommand("setContext", "commandtree.hasFilter", getTreeProvider().hasFilter()); } export function deactivate(): void { disposeDb(); + appState.reset(); } diff --git a/src/models/TaskItem.ts b/src/models/TaskItem.ts index ae5a68c..1b5dc1b 100644 --- a/src/models/TaskItem.ts +++ b/src/models/TaskItem.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode"; -import * as path from "path"; export type { Result, Ok, Err } from "./Result"; export { ok, err } from "./Result"; +export { isCommandItem, simplifyPath, generateCommandId, isPrivateTask } from "./taskHelpers"; /** * Icon definition for a command type. Plain data — no VS Code dependency. @@ -68,18 +68,6 @@ export interface ParamDef { readonly flag?: string; } -/** - * Mutable parameter definition for building during discovery. - */ -export interface MutableParamDef { - name: string; - description?: string; - default?: string; - options?: string[]; - format?: ParamFormat; - flag?: string; -} - /** * Represents a discovered command. */ @@ -96,7 +84,6 @@ export interface CommandItem { readonly description?: string; readonly summary?: string; readonly securityWarning?: string; - readonly isPhony?: boolean; readonly line?: number; } @@ -116,7 +103,6 @@ export interface MutableCommandItem { description?: string; summary?: string; securityWarning?: string; - isPhony?: boolean; line?: number; } @@ -140,13 +126,6 @@ export interface FolderNode { */ export type NodeData = CommandItem | CategoryNode | FolderNode; -/** - * Type guard: true when data is a CommandItem (command leaf). - */ -export function isCommandItem(data: NodeData | null | undefined): data is CommandItem { - return data !== null && data !== undefined && !("nodeType" in data); -} - /** * Pre-computed display properties for a CommandTreeItem. */ @@ -200,42 +179,3 @@ export class CommandTreeItem extends vscode.TreeItem { } } } - -/** - * Simplifies a file path to a readable category. - */ -export function simplifyPath(filePath: string, workspaceRoot: string): string { - const relative = path.relative(workspaceRoot, path.dirname(filePath)); - if (relative === "" || relative === ".") { - return "Root"; - } - - const parts = relative.split(path.sep); - if (parts.length > 3) { - const first = parts[0]; - const last = parts[parts.length - 1]; - if (first !== undefined && last !== undefined) { - return `${first}/.../${last}`; - } - } - return relative.split("\\").join("/"); -} - -/** - * Generates a unique ID for a command. - */ -export function generateCommandId(type: CommandType, filePath: string, name: string): string { - return `${type}:${filePath}:${name}`; -} - -function supportsPrivateTaskStyling(type: CommandType): boolean { - return type === "make" || type === "mise"; -} - -export function isPrivateTask(task: CommandItem): boolean { - return supportsPrivateTaskStyling(task.type) && task.label.startsWith("_"); -} - -export function isPhonyTask(task: CommandItem): boolean { - return task.type === "make" && task.isPhony === true; -} diff --git a/src/models/taskHelpers.ts b/src/models/taskHelpers.ts new file mode 100644 index 0000000..0e21aa3 --- /dev/null +++ b/src/models/taskHelpers.ts @@ -0,0 +1,44 @@ +import * as path from "path"; +import type { CommandItem, CommandType, NodeData } from "./TaskItem"; + +/** + * Type guard: true when data is a CommandItem (command leaf). + */ +export function isCommandItem(data: NodeData | null | undefined): data is CommandItem { + return data !== null && data !== undefined && !("nodeType" in data); +} + +/** + * Simplifies a file path to a readable category. + */ +export function simplifyPath(filePath: string, workspaceRoot: string): string { + const relative = path.relative(workspaceRoot, path.dirname(filePath)); + if (relative === "" || relative === ".") { + return "Root"; + } + + const parts = relative.split(path.sep); + if (parts.length > 3) { + const first = parts[0]; + const last = parts[parts.length - 1]; + if (first !== undefined && last !== undefined) { + return `${first}/.../${last}`; + } + } + return relative.split("\\").join("/"); +} + +/** + * Generates a unique ID for a command. + */ +export function generateCommandId(type: CommandType, filePath: string, name: string): string { + return `${type}:${filePath}:${name}`; +} + +function supportsPrivateTaskStyling(type: CommandType): boolean { + return type === "make" || type === "mise"; +} + +export function isPrivateTask(task: CommandItem): boolean { + return supportsPrivateTaskStyling(task.type) && task.label.startsWith("_"); +} diff --git a/src/runners/TaskRunner.ts b/src/runners/TaskRunner.ts index 5bf5342..def053d 100644 --- a/src/runners/TaskRunner.ts +++ b/src/runners/TaskRunner.ts @@ -1,5 +1,7 @@ import * as vscode from "vscode"; import type { CommandItem, ParamDef } from "../models/TaskItem"; +import { buildCommand } from "./paramFormatting"; +import type { ParamValue } from "./paramFormatting"; /** * SPEC: command-execution, parameterized-commands @@ -67,8 +69,8 @@ export class TaskRunner { /** * Collects parameter values from user with their definitions. */ - private async collectParams(params?: readonly ParamDef[]): Promise<Array<{ def: ParamDef; value: string }> | null> { - const collected: Array<{ def: ParamDef; value: string }> = []; + private async collectParams(params?: readonly ParamDef[]): Promise<ParamValue[] | null> { + const collected: ParamValue[] = []; if (params === undefined || params.length === 0) { return collected; } @@ -142,8 +144,8 @@ export class TaskRunner { /** * Runs a command in a new terminal. */ - private runInNewTerminal(task: CommandItem, params: Array<{ def: ParamDef; value: string }>): void { - const command = this.buildCommand(task, params); + private runInNewTerminal(task: CommandItem, params: readonly ParamValue[]): void { + const command = buildCommand(task.command, params); const terminalOptions: vscode.TerminalOptions = { name: `CommandTree: ${task.label}`, }; @@ -158,8 +160,8 @@ export class TaskRunner { /** * Runs a command in the current (active) terminal. */ - private runInCurrentTerminal(task: CommandItem, params: Array<{ def: ParamDef; value: string }>): void { - const command = this.buildCommand(task, params); + private runInCurrentTerminal(task: CommandItem, params: readonly ParamValue[]): void { + const command = buildCommand(task.command, params); let terminal = vscode.window.activeTerminal; if (terminal === undefined) { @@ -239,55 +241,4 @@ export class TaskRunner { showError(`Failed to send command to terminal: ${command}`); } } - - /** - * Builds the full command string with formatted parameters. - */ - private buildCommand(task: CommandItem, params: Array<{ def: ParamDef; value: string }>): string { - let { command } = task; - const parts: string[] = []; - - for (const { def, value } of params) { - if (value === "") { - continue; - } - const formatted = this.formatParam(def, value); - if (formatted !== "") { - parts.push(formatted); - } - } - - if (parts.length > 0) { - command = `${command} ${parts.join(" ")}`; - } - return command; - } - - /** - * Formats a parameter value according to its format type. - */ - private formatParam(def: ParamDef, value: string): string { - const format = def.format ?? "positional"; - - switch (format) { - case "positional": { - return `"${value}"`; - } - case "flag": { - const flagName = def.flag ?? `--${def.name}`; - return `${flagName} "${value}"`; - } - case "flag-equals": { - const flagName = def.flag ?? `--${def.name}`; - return `${flagName}=${value}`; - } - case "dashdash-args": { - return `-- ${value}`; - } - default: { - const exhaustive: never = format; - return exhaustive; - } - } - } } diff --git a/src/runners/paramFormatting.ts b/src/runners/paramFormatting.ts new file mode 100644 index 0000000..dd41467 --- /dev/null +++ b/src/runners/paramFormatting.ts @@ -0,0 +1,38 @@ +import type { ParamDef } from "../models/TaskItem"; + +export interface ParamValue { + readonly def: ParamDef; + readonly value: string; +} + +export function formatParam(def: ParamDef, value: string): string { + const format = def.format ?? "positional"; + const flag = def.flag ?? `--${def.name}`; + if (format === "flag") { + return `${flag} "${value}"`; + } + if (format === "flag-equals") { + return `${flag}=${value}`; + } + if (format === "dashdash-args") { + return `-- ${value}`; + } + return `"${value}"`; +} + +export function buildCommand(baseCommand: string, params: readonly ParamValue[]): string { + const parts: string[] = []; + for (const { def, value } of params) { + if (value === "") { + continue; + } + const formatted = formatParam(def, value); + if (formatted !== "") { + parts.push(formatted); + } + } + if (parts.length === 0) { + return baseCommand; + } + return `${baseCommand} ${parts.join(" ")}`; +} diff --git a/src/semantic/modelSelection.ts b/src/semantic/modelSelection.ts index 066fede..24c36e8 100644 --- a/src/semantic/modelSelection.ts +++ b/src/semantic/modelSelection.ts @@ -10,6 +10,8 @@ const err = <E>(error: E): Result<never, E> => ({ ok: false, error }); /** The "Auto" virtual model ID — not a real endpoint. */ export const AUTO_MODEL_ID = "auto"; +const NO_MODEL_ERROR = "No Copilot model available after retries"; +const PICKER_CANCELLED_ERROR = "Model selection cancelled"; /** Minimal model reference for selection logic. */ export interface ModelRef { @@ -41,31 +43,64 @@ export function pickConcreteModel(params: { return params.models.find((m) => m.id === params.preferredId); } +async function findSavedModel(deps: ModelSelectionDeps, savedId: string): Promise<ModelRef | undefined> { + if (savedId === "") { + return undefined; + } + const exact = await deps.fetchById(savedId); + return exact[0]; +} + +async function fetchAvailableModels(deps: ModelSelectionDeps): Promise<Result<readonly ModelRef[], string>> { + const allModels = await deps.fetchAll(); + return allModels.length > 0 ? ok(allModels) : err(NO_MODEL_ERROR); +} + +async function promptAndSaveModel( + deps: ModelSelectionDeps, + models: readonly ModelRef[] +): Promise<Result<ModelRef, string>> { + const picked = await deps.promptUser(models); + if (picked === undefined) { + return err(PICKER_CANCELLED_ERROR); + } + await deps.saveId(picked.id); + return ok(picked); +} + /** * Pure model selection logic. Uses saved setting if available, * otherwise prompts user and persists the choice. */ export async function resolveModel(deps: ModelSelectionDeps): Promise<Result<ModelRef, string>> { const savedId = deps.getSavedId(); + const saved = await findSavedModel(deps, savedId); + if (saved !== undefined) { + return ok(saved); + } - if (savedId !== "") { - const exact = await deps.fetchById(savedId); - const first = exact[0]; - if (first !== undefined) { - return ok(first); - } + const allResult = await fetchAvailableModels(deps); + if (!allResult.ok) { + return allResult; } + return await promptAndSaveModel(deps, allResult.value); +} - const allModels = await deps.fetchAll(); - if (allModels.length === 0) { - return err("No Copilot model available after retries"); +/** + * Pure background model selection. Uses saved setting if valid, + * otherwise chooses an available concrete model without user prompts. + */ +export async function resolveModelAutomatically(deps: ModelSelectionDeps): Promise<Result<ModelRef, string>> { + const saved = await findSavedModel(deps, deps.getSavedId()); + if (saved !== undefined) { + return ok(saved); } - const picked = await deps.promptUser(allModels); - if (picked === undefined) { - return err("Model selection cancelled"); + const allResult = await fetchAvailableModels(deps); + if (!allResult.ok) { + return allResult; } - await deps.saveId(picked.id); - return ok(picked); + const automatic = pickConcreteModel({ models: allResult.value, preferredId: AUTO_MODEL_ID }); + return automatic !== undefined ? ok(automatic) : err(NO_MODEL_ERROR); } diff --git a/src/semantic/summariser.ts b/src/semantic/summariser.ts index c808d56..34cac35 100644 --- a/src/semantic/summariser.ts +++ b/src/semantic/summariser.ts @@ -8,7 +8,7 @@ import * as vscode from "vscode"; import type { Result } from "../models/Result"; import { ok, err } from "../models/Result"; import { logger } from "../utils/logger"; -import { resolveModel, pickConcreteModel } from "./modelSelection"; +import { resolveModel, resolveModelAutomatically, pickConcreteModel } from "./modelSelection"; import type { ModelSelectionDeps, ModelRef } from "./modelSelection"; export type { ModelRef, ModelSelectionDeps } from "./modelSelection"; export { resolveModel, AUTO_MODEL_ID, pickConcreteModel } from "./modelSelection"; @@ -24,6 +24,8 @@ export interface SummaryResult { readonly securityWarning: string; } +export type ModelSelectionMode = "interactive" | "automatic"; + const ANALYSIS_TOOL: vscode.LanguageModelChatTool = { name: TOOL_NAME, description: "Report the analysis of a command including summary and any security warnings", @@ -130,20 +132,18 @@ function buildVSCodeDeps(): ModelSelectionDeps { * Selects the configured model by ID, or prompts the user to pick one. * When "auto" is selected, uses the Copilot auto model directly. */ -export async function selectCopilotModel(): Promise<Result<vscode.LanguageModelChat, string>> { - const result = await resolveModel(buildVSCodeDeps()); - if (!result.ok) { - return result; - } - - const allModels = await fetchModels({ vendor: "copilot" }); - if (allModels.length === 0) { - return err("No Copilot models available"); - } +async function resolveModelRef(mode: ModelSelectionMode | undefined): Promise<Result<ModelRef, string>> { + const deps = buildVSCodeDeps(); + return mode === "automatic" ? await resolveModelAutomatically(deps) : await resolveModel(deps); +} +function findResolvedModel( + allModels: readonly vscode.LanguageModelChat[], + selectedId: string +): Result<vscode.LanguageModelChat, string> { const model = pickConcreteModel({ models: allModels.map((m) => ({ id: m.id, name: m.name })), - preferredId: result.value.id, + preferredId: selectedId, }); if (!model) { return err("Selected model no longer available"); @@ -153,12 +153,33 @@ export async function selectCopilotModel(): Promise<Result<vscode.LanguageModelC if (!resolved) { return err("Selected model no longer available"); } + return ok(resolved); +} + +async function fetchResolvedModel(selectedId: string): Promise<Result<vscode.LanguageModelChat, string>> { + const allModels = await fetchModels({ vendor: "copilot" }); + if (allModels.length === 0) { + return err("No Copilot models available"); + } + const result = findResolvedModel(allModels, selectedId); + if (!result.ok) { + return result; + } logger.info("Resolved model for requests", { - selected: result.value.id, - resolved: resolved.id, + selected: selectedId, + resolved: result.value.id, }); - return ok(resolved); + return result; +} + +export async function selectCopilotModel( + params: { + readonly mode?: ModelSelectionMode | undefined; + } = {} +): Promise<Result<vscode.LanguageModelChat, string>> { + const result = await resolveModelRef(params.mode); + return result.ok ? await fetchResolvedModel(result.value.id) : result; } /** diff --git a/src/semantic/summaryPipeline.ts b/src/semantic/summaryPipeline.ts index ebd40f6..14258cf 100644 --- a/src/semantic/summaryPipeline.ts +++ b/src/semantic/summaryPipeline.ts @@ -12,6 +12,7 @@ import { logger } from "../utils/logger"; import { computeContentHash } from "../db/db"; import type { FileSystemAdapter } from "./adapters"; import type { SummaryResult } from "./summariser"; +import type { ModelSelectionMode } from "./summariser"; import { selectCopilotModel, summariseScript } from "./summariser"; import { initDb, getDb } from "../db/lifecycle"; import { upsertSummary, getRow, registerCommand } from "../db/db"; @@ -168,59 +169,101 @@ async function processPendingItem(params: { } } -/** - * Summarises all tasks that are new or have changed content. - * Stores summaries in SQLite. - * Commands are registered in DB BEFORE Copilot is contacted. - */ -export async function summariseAllTasks(params: { +async function getSummaryDbHandle(params: { readonly tasks: readonly CommandItem[]; readonly workspaceRoot: string; readonly fs: FileSystemAdapter; - readonly onProgress?: (done: number, total: number, label: string) => void; -}): Promise<Result<number, string>> { - // Step 1: Always register commands in DB (independent of Copilot) +}): Promise<Result<DbHandle, string>> { const regResult = await registerAllCommands(params); if (!regResult.ok) { logger.error("[SUMMARY] registerAllCommands failed", { error: regResult.error }); return err(regResult.error); } + const dbResult = getDb(); + return dbResult.ok ? ok(dbResult.value) : err(dbResult.error); +} - // Step 2: Try Copilot — if unavailable, commands are still in DB - const modelResult = await selectCopilotModel(); +async function selectSummaryModel( + mode: ModelSelectionMode | undefined +): Promise<Result<vscode.LanguageModelChat, string>> { + const modelResult = await selectCopilotModel({ mode }); if (!modelResult.ok) { logger.error("[SUMMARY] Copilot model selection failed", { error: modelResult.error }); - return err(modelResult.error); - } - - const dbResult = getDb(); - if (!dbResult.ok) { - return err(dbResult.error); - } - const handle = dbResult.value; - - const pending = await findPendingSummaries({ - handle, - tasks: params.tasks, - fs: params.fs, - }); - if (pending.length === 0) { - logger.info("[SUMMARY] All summaries up to date"); - return ok(0); } + return modelResult; +} +async function processPendingBatch(params: { + readonly pending: readonly PendingItem[]; + readonly model: vscode.LanguageModelChat; + readonly handle: DbHandle; + readonly onProgress?: ((done: number, total: number, label: string) => void) | undefined; +}): Promise<BatchState> { const state: BatchState = { succeeded: 0, failed: 0, aborted: false }; - for (const item of pending) { - await processPendingItem({ item, model: modelResult.value, handle, state }); - params.onProgress?.(state.succeeded + state.failed, pending.length, item.task.label); + for (const item of params.pending) { + await processPendingItem({ item, model: params.model, handle: params.handle, state }); + params.onProgress?.(state.succeeded + state.failed, params.pending.length, item.task.label); if (state.aborted) { break; } } + return state; +} +function batchStateToResult(state: BatchState): Result<number, string> { logger.info("[SUMMARY] complete", { succeeded: state.succeeded, failed: state.failed }); if (state.succeeded === 0 && state.failed > 0) { return err(`All ${state.failed} tasks failed to summarise`); } return ok(state.succeeded); } + +async function runPendingSummaries(params: { + readonly tasks: readonly CommandItem[]; + readonly fs: FileSystemAdapter; + readonly handle: DbHandle; + readonly model: vscode.LanguageModelChat; + readonly onProgress?: ((done: number, total: number, label: string) => void) | undefined; +}): Promise<Result<number, string>> { + const pending = await findPendingSummaries({ handle: params.handle, tasks: params.tasks, fs: params.fs }); + if (pending.length === 0) { + logger.info("[SUMMARY] All summaries up to date"); + return ok(0); + } + const state = await processPendingBatch({ + pending, + model: params.model, + handle: params.handle, + onProgress: params.onProgress, + }); + return batchStateToResult(state); +} + +/** + * Summarises all tasks that are new or have changed content. + * Stores summaries in SQLite. + * Commands are registered in DB BEFORE Copilot is contacted. + */ +export async function summariseAllTasks(params: { + readonly tasks: readonly CommandItem[]; + readonly workspaceRoot: string; + readonly fs: FileSystemAdapter; + readonly modelSelectionMode?: ModelSelectionMode | undefined; + readonly onProgress?: (done: number, total: number, label: string) => void; +}): Promise<Result<number, string>> { + const handleResult = await getSummaryDbHandle(params); + if (!handleResult.ok) { + return handleResult; + } + const modelResult = await selectSummaryModel(params.modelSelectionMode); + if (!modelResult.ok) { + return modelResult; + } + return await runPendingSummaries({ + tasks: params.tasks, + fs: params.fs, + handle: handleResult.value, + model: modelResult.value, + onProgress: params.onProgress, + }); +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..9f80f9a --- /dev/null +++ b/src/state.ts @@ -0,0 +1,28 @@ +/** + * Centralized runtime state for the CommandTree extension. + * All mutable global state lives here — no module-level `let` anywhere else. + * A single `appState` instance owns the DB handle, tree providers, and runner. + */ + +import type { DbHandle } from "./db/db"; +import type { CommandTreeProvider } from "./CommandTreeProvider"; +import type { QuickTasksProvider } from "./QuickTasksProvider"; +import type { TaskRunner } from "./runners/TaskRunner"; + +class AppState { + public dbHandle: DbHandle | null = null; + public treeProvider: CommandTreeProvider | undefined = undefined; + public quickTasksProvider: QuickTasksProvider | undefined = undefined; + public taskRunner: TaskRunner | undefined = undefined; + public activated = false; + + public reset(): void { + this.dbHandle = null; + this.treeProvider = undefined; + this.quickTasksProvider = undefined; + this.taskRunner = undefined; + this.activated = false; + } +} + +export const appState = new AppState(); diff --git a/src/summaryOrchestration.ts b/src/summaryOrchestration.ts new file mode 100644 index 0000000..1a4662e --- /dev/null +++ b/src/summaryOrchestration.ts @@ -0,0 +1,102 @@ +/** + * SPEC: SPEC-AI-010, SPEC-AI-030 + * Coordinates automatic and user-triggered AI summary runs. + */ +import * as vscode from "vscode"; +import type { CommandTreeProvider } from "./CommandTreeProvider"; +import type { QuickTasksProvider } from "./QuickTasksProvider"; +import type { Result } from "./models/Result"; +import { ok } from "./models/Result"; +import { logger } from "./utils/logger"; +import { summariseAllTasks, registerAllCommands } from "./semantic/summaryPipeline"; +import { createVSCodeFileSystem } from "./semantic/vscodeAdapters"; +import type { ModelSelectionMode } from "./semantic/summariser"; + +export interface SummaryDeps { + readonly workspaceRoot: string; + readonly treeProvider: CommandTreeProvider; + readonly quickTasksProvider: QuickTasksProvider; +} + +interface RunSummaryParams extends SummaryDeps { + readonly modelSelectionMode?: ModelSelectionMode | undefined; +} + +function aiSummariesEnabled(): boolean { + const aiConfig = vscode.workspace.getConfiguration("commandtree").get<boolean>("enableAiSummaries"); + return aiConfig !== false; +} + +async function refreshSummaryViews(params: SummaryDeps): Promise<void> { + await params.treeProvider.refresh(); + params.quickTasksProvider.updateTasks(params.treeProvider.getAllTasks()); +} + +async function summariseCurrentTasks(params: RunSummaryParams): Promise<Result<number, string>> { + const tasks = params.treeProvider.getAllTasks(); + logger.info("[SUMMARY] Starting", { taskCount: tasks.length }); + if (tasks.length === 0) { + logger.warn("[SUMMARY] No tasks to summarise"); + return ok(0); + } + return await summariseAllTasks({ + tasks, + workspaceRoot: params.workspaceRoot, + fs: createVSCodeFileSystem(), + modelSelectionMode: params.modelSelectionMode, + onProgress: (done, total, label) => { + logger.info(`[SUMMARY] ${label}`, { done, total }); + }, + }); +} + +export async function registerDiscoveredCommands(params: SummaryDeps): Promise<void> { + const tasks = params.treeProvider.getAllTasks(); + if (tasks.length === 0) { + return; + } + const result = await registerAllCommands({ + tasks, + workspaceRoot: params.workspaceRoot, + fs: createVSCodeFileSystem(), + }); + if (!result.ok) { + logger.warn("Command registration failed", { error: result.error }); + return; + } + logger.info("Commands registered in DB", { count: result.value }); +} + +export function initAiSummaries(params: SummaryDeps): void { + if (!aiSummariesEnabled()) { + return; + } + vscode.commands.executeCommand("setContext", "commandtree.aiSummariesEnabled", true); + runSummarisation({ ...params, modelSelectionMode: "automatic" }).catch((e: unknown) => { + logger.error("AI summarisation failed", { + error: e instanceof Error ? e.message : "Unknown", + }); + }); +} + +export async function runSummarisation(params: RunSummaryParams): Promise<void> { + const summaryResult = await summariseCurrentTasks(params); + if (!summaryResult.ok) { + logger.error("Summary pipeline failed", { error: summaryResult.error }); + vscode.window.showErrorMessage(`CommandTree: Summary failed — ${summaryResult.error}`); + return; + } + if (summaryResult.value > 0) { + await refreshSummaryViews(params); + } + vscode.window.showInformationMessage(`CommandTree: Summarised ${summaryResult.value} commands`); +} + +export async function syncAndSummarise(params: SummaryDeps): Promise<void> { + await params.treeProvider.refresh(); + params.quickTasksProvider.updateTasks(params.treeProvider.getAllTasks()); + await registerDiscoveredCommands(params); + if (aiSummariesEnabled()) { + await runSummarisation({ ...params, modelSelectionMode: "automatic" }); + } +} diff --git a/src/test/e2e/copyPath.e2e.test.ts b/src/test/e2e/copyPath.e2e.test.ts new file mode 100644 index 0000000..1964799 --- /dev/null +++ b/src/test/e2e/copyPath.e2e.test.ts @@ -0,0 +1,125 @@ +/** + * SPEC: command-tree-copy-path + * E2E coverage for copying task file paths from CommandTree context menus. + */ + +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { activateExtension, collectLeafItems, getCommandTreeProvider, getExtensionPath } from "../helpers/helpers"; +import { parsePackageJson } from "../helpers/test-types"; +import type { PackageJsonMenuItem } from "../helpers/test-types"; +import { isCommandItem } from "../../models/TaskItem"; +import type { CommandItem, CommandTreeItem } from "../../models/TaskItem"; + +const COPY_RELATIVE_COMMAND = "commandtree.copyRelativePath"; +const COPY_FULL_COMMAND = "commandtree.copyFullPath"; +const SENTINEL_CLIPBOARD = "sentinel clipboard value"; + +function readPackageJsonCommands(): string[] { + const content = fs.readFileSync(getExtensionPath("package.json"), "utf8"); + return parsePackageJson(content).contributes.commands?.map((command) => command.command) ?? []; +} + +function readContextMenus(): PackageJsonMenuItem[] { + const content = fs.readFileSync(getExtensionPath("package.json"), "utf8"); + return parsePackageJson(content).contributes.menus?.["view/item/context"] ?? []; +} + +function menuFor(command: string): PackageJsonMenuItem | undefined { + return readContextMenus().find((menu) => menu.command === command); +} + +function assertTaskContextMenu(command: string): void { + const menu = menuFor(command); + assert.ok(menu !== undefined, `${command} should be contributed to the task context menu`); + assert.ok(menu.when?.includes("view == commandtree") === true, `${command} should appear in CommandTree`); + assert.ok(menu.when.includes("viewItem"), `${command} should be scoped to task tree items`); +} + +async function findCommandTreeFileItem(): Promise<CommandTreeItem> { + const items = await collectLeafItems(getCommandTreeProvider()); + const buildScript = items.find((item) => isCommandItem(item.data) && item.data.filePath.endsWith("scripts/build.sh")); + const fallback = items.find((item) => isCommandItem(item.data) && item.data.filePath !== ""); + const item = buildScript ?? fallback; + if (item === undefined) { + assert.fail("CommandTree should expose at least one file-backed command item"); + } + return item; +} + +function taskFromItem(item: CommandTreeItem): CommandItem { + if (!isCommandItem(item.data)) { + assert.fail("Expected a command tree item backed by a command"); + } + return item.data; +} + +async function assertClipboardValue(expected: string, message: string): Promise<void> { + const actual = await vscode.env.clipboard.readText(); + assert.strictEqual(actual, expected, message); +} + +suite("Copy Path E2E Tests", () => { + let workspaceRoot = ""; + + suiteSetup(async function () { + this.timeout(30000); + ({ workspaceRoot } = await activateExtension()); + }); + + test("copy path commands are registered and exposed on task context menus", async function () { + this.timeout(10000); + const registeredCommands = await vscode.commands.getCommands(true); + assert.ok(registeredCommands.includes(COPY_RELATIVE_COMMAND), "Copy Relative Path command should be registered"); + assert.ok(registeredCommands.includes(COPY_FULL_COMMAND), "Copy Full Path command should be registered"); + + const contributedCommands = readPackageJsonCommands(); + assert.ok(contributedCommands.includes(COPY_RELATIVE_COMMAND), "Copy Relative Path should be in package.json"); + assert.ok(contributedCommands.includes(COPY_FULL_COMMAND), "Copy Full Path should be in package.json"); + assertTaskContextMenu(COPY_RELATIVE_COMMAND); + assertTaskContextMenu(COPY_FULL_COMMAND); + }); + + test("copy path commands copy relative and full paths for command tree items", async function () { + this.timeout(15000); + const item = await findCommandTreeFileItem(); + const task = taskFromItem(item); + const relativePath = path.relative(workspaceRoot, task.filePath); + + assert.ok(task.filePath.length > 0, "Task should expose a file path"); + assert.ok(path.isAbsolute(task.filePath), "Full path should be absolute"); + assert.ok(relativePath.length > 0, "Relative path should not be empty"); + assert.ok(!path.isAbsolute(relativePath), "Relative path should not be absolute"); + + await vscode.env.clipboard.writeText(SENTINEL_CLIPBOARD); + await vscode.commands.executeCommand(COPY_RELATIVE_COMMAND, item); + await assertClipboardValue(relativePath, "Copy Relative Path should write workspace-relative path"); + + await vscode.commands.executeCommand(COPY_FULL_COMMAND, item); + await assertClipboardValue(task.filePath, "Copy Full Path should write absolute file path"); + + await vscode.env.clipboard.writeText(SENTINEL_CLIPBOARD); + await vscode.commands.executeCommand(COPY_RELATIVE_COMMAND, undefined); + await assertClipboardValue(SENTINEL_CLIPBOARD, "Undefined items should not change the clipboard"); + + await vscode.env.clipboard.writeText(SENTINEL_CLIPBOARD); + await vscode.commands.executeCommand(COPY_FULL_COMMAND, undefined); + await assertClipboardValue(SENTINEL_CLIPBOARD, "Copy Full Path must be a no-op when invoked with no tree item"); + + await vscode.env.clipboard.writeText(SENTINEL_CLIPBOARD); + await vscode.commands.executeCommand(COPY_FULL_COMMAND, { data: { nodeType: "category", commandType: "make" } }); + await assertClipboardValue( + SENTINEL_CLIPBOARD, + "Copy Full Path must be a no-op when invoked on a non-command tree node" + ); + + await vscode.env.clipboard.writeText(SENTINEL_CLIPBOARD); + await vscode.commands.executeCommand(COPY_RELATIVE_COMMAND, { data: { nodeType: "folder" } }); + await assertClipboardValue( + SENTINEL_CLIPBOARD, + "Copy Relative Path must be a no-op when invoked on a folder tree node" + ); + }); +}); diff --git a/src/test/e2e/coverage.e2e.test.ts b/src/test/e2e/coverage.e2e.test.ts new file mode 100644 index 0000000..8b4dcf4 --- /dev/null +++ b/src/test/e2e/coverage.e2e.test.ts @@ -0,0 +1,267 @@ +import * as assert from "assert"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + activateExtension, + deleteFile, + getFixturePath, + getQuickTasksProvider, + getCommandTreeProvider, + writeFile, +} from "../helpers/helpers"; +import type { QuickTasksProvider } from "../helpers/helpers"; +import { getDbOrThrow, initDb } from "../../db/lifecycle"; +import { getCommandIdsByTag } from "../../db/db"; +import { createCommandNode } from "../../tree/nodeFactory"; +import { isCommandItem } from "../../models/TaskItem"; +import type { CommandItem, CommandTreeItem } from "../../models/TaskItem"; +import { readFile, readFileContent, readJsonFile, parseFirstLineComment } from "../../utils/fileUtils"; +import { logger } from "../../utils/logger"; +import { PrivateTaskDecorationProvider, buildPrivateTaskUri } from "../../tree/PrivateTaskDecorationProvider"; +import { discoverCsharpScripts } from "../../discovery/csharp-script"; +import { discoverFsharpScripts } from "../../discovery/fsharp-script"; + +const QUICK_TAG = "quick"; +const QUICK_MIME = "application/vnd.commandtree.quicktask"; +const JSONC_PATH = "coverage-fixtures/config.jsonc"; +const BAD_JSON_PATH = "coverage-fixtures/bad.jsonc"; +const CSHARP_PATH = "coverage-fixtures/example.csx"; +const FSHARP_PATH = "coverage-fixtures/example.fsx"; + +function commandIds(): string[] { + return getCommandIdsByTag({ handle: getDbOrThrow(), tagName: QUICK_TAG }); +} + +async function addQuickTask(item: vscode.TreeItem): Promise<void> { + await vscode.commands.executeCommand("commandtree.addToQuick", item); +} + +async function removeQuickTask(item: vscode.TreeItem): Promise<void> { + await vscode.commands.executeCommand("commandtree.removeFromQuick", item); +} + +interface QuickCoverageState { + readonly quickProvider: QuickTasksProvider; + readonly tasks: readonly [CommandItem, CommandItem, CommandItem]; + readonly nodes: readonly CommandTreeItem[]; + readonly initialIds: readonly string[]; +} + +interface SameTargetParams { + readonly quickProvider: QuickTasksProvider; + readonly source: CommandTreeItem; + readonly task: CommandItem; + readonly initialIds: readonly string[]; +} + +interface ReorderBeforeParams { + readonly quickProvider: QuickTasksProvider; + readonly dragged: CommandTreeItem; + readonly target: CommandTreeItem; + readonly draggedTask: CommandItem; + readonly targetTask: CommandItem; +} + +function firstThreeTasks(tasks: readonly CommandItem[]): [CommandItem, CommandItem, CommandItem] { + const first = tasks[0]; + const second = tasks[1]; + const third = tasks[2]; + if (first === undefined || second === undefined || third === undefined) { + assert.fail("Need three discovered tasks for drag/drop coverage"); + } + return [first, second, third]; +} + +function assertQuickTasksPresent(tasks: readonly CommandItem[], ids: readonly string[]): void { + for (const task of tasks) { + assert.ok(ids.includes(task.id), `Quick list should contain ${task.label}`); + } +} + +async function setupQuickCoverage(): Promise<QuickCoverageState> { + const quickProvider = getQuickTasksProvider(); + const tasks = firstThreeTasks(getCommandTreeProvider().getAllTasks()); + const nodes = tasks.map((task) => createCommandNode(task)); + for (const node of nodes) { + await addQuickTask(node); + } + const initialIds = commandIds(); + assertQuickTasksPresent(tasks, initialIds); + return { quickProvider, tasks, nodes, initialIds }; +} + +function assertEmptyAndInvalidDrops(quickProvider: QuickTasksProvider, initialIds: readonly string[]): void { + const emptyDrag = new vscode.DataTransfer(); + quickProvider.handleDrag([], emptyDrag); + assert.strictEqual(emptyDrag.get(QUICK_MIME), undefined, "Empty drag source should not set transfer data"); + const invalidDrop = new vscode.DataTransfer(); + quickProvider.handleDrop(undefined, invalidDrop); + assert.deepStrictEqual(commandIds(), initialIds, "Drop without transfer data should not reorder"); +} + +function visibleQuickItems(quickProvider: QuickTasksProvider): CommandTreeItem[] { + const quickItems = quickProvider.getChildren().filter((item) => isCommandItem(item.data)); + assert.ok(quickItems.length >= 3, "Quick provider should expose command rows"); + return quickItems; +} + +function commandItemForTask(items: readonly CommandTreeItem[], task: CommandItem): CommandTreeItem { + const item = items.find((candidate) => isCommandItem(candidate.data) && candidate.data.id === task.id); + if (item === undefined) { + assert.fail(`Quick provider should expose ${task.label}`); + } + return item; +} + +function assertSameTargetDoesNotReorder({ quickProvider, source, task, initialIds }: SameTargetParams): void { + const sameTargetDrag = new vscode.DataTransfer(); + quickProvider.handleDrag([source], sameTargetDrag); + assert.strictEqual(sameTargetDrag.get(QUICK_MIME)?.value, task.id, "Drag should carry task ID"); + quickProvider.handleDrop(source, sameTargetDrag); + assert.deepStrictEqual(commandIds(), initialIds, "Dropping onto itself should not reorder"); +} + +function assertReorderBefore({ quickProvider, dragged, target, draggedTask, targetTask }: ReorderBeforeParams): void { + const reorderDrag = new vscode.DataTransfer(); + quickProvider.handleDrag([dragged], reorderDrag); + quickProvider.handleDrop(target, reorderDrag); + const reordered = commandIds(); + const targetIndex = reordered.indexOf(targetTask.id); + const draggedIndex = reordered.indexOf(draggedTask.id); + assert.ok(draggedIndex !== -1 && targetIndex !== -1, "Dragged and target tasks should stay in quick list"); + assert.ok(draggedIndex < targetIndex, "Dragged item should move before the target item"); +} + +function assertDropToEnd(quickProvider: QuickTasksProvider, source: CommandTreeItem, task: CommandItem): void { + const dropToEnd = new vscode.DataTransfer(); + quickProvider.handleDrag([source], dropToEnd); + quickProvider.handleDrop(undefined, dropToEnd); + const endOrder = commandIds(); + assert.strictEqual(endOrder.at(-1), task.id, "Dropping with no target should move item to the end"); +} + +async function cleanupQuickNodes(nodes: readonly CommandTreeItem[]): Promise<void> { + for (const node of nodes) { + await removeQuickTask(node); + } +} + +suite("Coverage E2E Tests", () => { + let workspaceRoot = ""; + + suiteSetup(async function () { + this.timeout(30000); + ({ workspaceRoot } = await activateExtension()); + const dbResult = await initDb(workspaceRoot); + assert.ok(dbResult.ok, "Coverage tests should have an initialized database"); + }); + + teardown(() => { + deleteFile(JSONC_PATH); + deleteFile(BAD_JSON_PATH); + deleteFile(CSHARP_PATH); + deleteFile(FSHARP_PATH); + }); + + test("filesystem helpers, logger, and private decorations cover success and error paths", async function () { + this.timeout(15000); + writeFile(JSONC_PATH, ["{", " // removed", ' "name": "coverage",', ' "enabled": true', "}"].join("\n")); + writeFile(BAD_JSON_PATH, '{ "name": '); + const validUri = vscode.Uri.file(getFixturePath(JSONC_PATH)); + const badUri = vscode.Uri.file(getFixturePath(BAD_JSON_PATH)); + const missingUri = vscode.Uri.file(getFixturePath("coverage-fixtures/missing.json")); + + const readResult = await readFile(validUri); + assert.ok(readResult.ok, "readFile should return ok for an existing file"); + assert.ok(readResult.value.includes("coverage"), "readFile should decode file contents"); + assert.strictEqual(await readFileContent(validUri), readResult.value, "readFileContent should return raw text"); + + const parsed = await readJsonFile<{ name: string; enabled: boolean }>(validUri); + assert.ok(parsed.ok, "readJsonFile should parse JSONC after stripping comments"); + assert.strictEqual(parsed.value.name, "coverage", "Parsed JSONC should expose string values"); + assert.strictEqual(parsed.value.enabled, true, "Parsed JSONC should expose boolean values"); + + const badJson = await readJsonFile<unknown>(badUri); + assert.ok(!badJson.ok, "readJsonFile should return err for malformed JSON"); + assert.ok(badJson.error.length > 0, "Malformed JSON error should include a message"); + const missing = await readJsonFile<unknown>(missingUri); + assert.ok(!missing.ok, "readJsonFile should return err for missing files"); + assert.ok(missing.error.length > 0, "Missing file error should include a message"); + + assert.strictEqual(parseFirstLineComment("\n\n// Hello\ncode", "//"), "Hello", "Should skip blank lines"); + assert.strictEqual(parseFirstLineComment("//\ncode", "//"), undefined, "Empty comments should be ignored"); + assert.strictEqual(parseFirstLineComment("code\n// later", "//"), undefined, "Later comments should not count"); + + logger.show(); + logger.info("coverage info"); + logger.info("coverage info data", { count: 1 }); + logger.warn("coverage warn"); + logger.warn("coverage warn data", { count: 2 }); + logger.error("coverage error"); + logger.error("coverage error data", { count: 3 }); + logger.filter("coverage", { active: true }); + + const decorations = new PrivateTaskDecorationProvider(); + const privateDecoration = decorations.provideFileDecoration(buildPrivateTaskUri("coverage-task")); + assert.ok(privateDecoration !== undefined, "Private task URI should produce a decoration"); + assert.strictEqual(privateDecoration.color?.id, "descriptionForeground", "Private decoration should be muted"); + assert.strictEqual(privateDecoration.tooltip, "Private task", "Private decoration should identify private tasks"); + assert.strictEqual( + decorations.provideFileDecoration(validUri), + undefined, + "Normal file URI should not be decorated" + ); + }); + + test("C# and F# script discovery covers described and executable script rows", async function () { + this.timeout(15000); + writeFile(CSHARP_PATH, ["// C# script description", 'Console.WriteLine("hello");'].join("\n")); + writeFile(FSHARP_PATH, ["// F# script description", 'let message = "hello"'].join("\n")); + + const csharp = await discoverCsharpScripts(workspaceRoot, []); + const fsharp = await discoverFsharpScripts(workspaceRoot, []); + const csItem = csharp.find((task) => task.filePath.endsWith(CSHARP_PATH)); + const fsItem = fsharp.find((task) => task.filePath.endsWith(FSHARP_PATH)); + + assert.ok(csItem !== undefined, "C# script should be discovered"); + assert.strictEqual(csItem.label, "example.csx", "C# script label should be the file name"); + assert.strictEqual(csItem.description, "C# script description", "C# description should come from first comment"); + assert.ok(csItem.command.startsWith("dotnet script"), "C# command should use dotnet script"); + assert.strictEqual(csItem.cwd, path.dirname(csItem.filePath), "C# cwd should be script directory"); + + assert.ok(fsItem !== undefined, "F# script should be discovered"); + assert.strictEqual(fsItem.label, "example.fsx", "F# script label should be the file name"); + assert.strictEqual(fsItem.description, "F# script description", "F# description should come from first comment"); + assert.ok(fsItem.command.startsWith("dotnet fsi"), "F# command should use dotnet fsi"); + assert.strictEqual(fsItem.cwd, path.dirname(fsItem.filePath), "F# cwd should be script directory"); + }); + + test("Quick Launch drag and drop covers empty, invalid, same-target, and reorder interactions", async function () { + this.timeout(30000); + const context = await setupQuickCoverage(); + + try { + assertEmptyAndInvalidDrops(context.quickProvider, context.initialIds); + const [firstTask, , thirdTask] = context.tasks; + const quickItems = visibleQuickItems(context.quickProvider); + const first = commandItemForTask(quickItems, firstTask); + const third = commandItemForTask(quickItems, thirdTask); + assertSameTargetDoesNotReorder({ + quickProvider: context.quickProvider, + source: first, + task: firstTask, + initialIds: context.initialIds, + }); + assertReorderBefore({ + quickProvider: context.quickProvider, + dragged: third, + target: first, + draggedTask: thirdTask, + targetTask: firstTask, + }); + assertDropToEnd(context.quickProvider, first, firstTask); + } finally { + await cleanupQuickNodes(context.nodes); + } + }); +}); diff --git a/src/test/e2e/dockerExecution.e2e.test.ts b/src/test/e2e/dockerExecution.e2e.test.ts new file mode 100644 index 0000000..a80a559 --- /dev/null +++ b/src/test/e2e/dockerExecution.e2e.test.ts @@ -0,0 +1,80 @@ +/** + * SPEC: command-tree-docker-execution + * E2E coverage for executable Dockerfile and Docker Compose rows. + */ + +import * as assert from "assert"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + activateExtension, + collectLeafTasks, + executeCommand, + getCommandTreeProvider, + getFixturePath, +} from "../helpers/helpers"; +import type { CommandItem } from "../../models/TaskItem"; + +const REFRESH_COMMAND = "commandtree.refresh"; +const DOCKERFILE_NAME = "Dockerfile"; +const COMPOSE_FILE_NAME = "docker-compose.yml"; + +function dockerTasks(): CommandItem[] { + return collectCachedTasks().filter((task) => task.type === "docker"); +} + +function collectCachedTasks(): CommandItem[] { + return getCommandTreeProvider().getAllTasks(); +} + +function findDockerTask(label: string, fileName: string): CommandItem | undefined { + return dockerTasks().find((task) => path.basename(task.filePath) === fileName && task.label === label); +} + +function assertDockerTask(task: CommandItem | undefined, label: string): CommandItem { + if (task === undefined) { + assert.fail(`Expected Docker task: ${label}`); + } + assert.strictEqual(task.type, "docker", `${label} should be a docker task`); + assert.ok(path.isAbsolute(task.filePath), `${label} should expose an absolute source path`); + return task; +} + +suite("Docker Execution E2E Tests", () => { + let workspaceRoot = ""; + + suiteSetup(async function () { + this.timeout(30000); + ({ workspaceRoot } = await activateExtension()); + }); + + test("Dockerfile and compose rows execute with file-specific docker commands", async function () { + this.timeout(20000); + await executeCommand(REFRESH_COMMAND); + const tasks = await collectLeafTasks(getCommandTreeProvider()); + const dockerTaskCount = tasks.filter((task) => task.type === "docker").length; + assert.ok(dockerTaskCount >= 8, "Tree should expose Dockerfile, compose, and compose service rows"); + + const dockerfilePath = getFixturePath(DOCKERFILE_NAME); + const composePath = getFixturePath(COMPOSE_FILE_NAME); + const dockerfileTask = assertDockerTask(findDockerTask("build Dockerfile", DOCKERFILE_NAME), "build Dockerfile"); + const composeUpTask = assertDockerTask(findDockerTask("up", COMPOSE_FILE_NAME), "docker compose up"); + const composeServiceTask = assertDockerTask(findDockerTask("up web", COMPOSE_FILE_NAME), "docker compose up web"); + + assert.strictEqual(dockerfileTask.filePath, dockerfilePath, "Dockerfile task should point at Dockerfile"); + assert.strictEqual(dockerfileTask.cwd, workspaceRoot, "Dockerfile task should run from its directory"); + assert.strictEqual(dockerfileTask.command, `docker build -f "${dockerfilePath}" .`, "Dockerfile should build file"); + assert.strictEqual(composeUpTask.filePath, composePath, "Compose up should point at compose file"); + assert.strictEqual(composeUpTask.cwd, workspaceRoot, "Compose up should run from compose file directory"); + assert.strictEqual(composeUpTask.command, `docker compose -f "${composePath}" up`, "Compose up should use -f"); + assert.strictEqual( + composeServiceTask.command, + `docker compose -f "${composePath}" up web`, + "Service up should use -f and the service name" + ); + assert.ok( + vscode.Uri.file(dockerfileTask.filePath).scheme === "file", + "Dockerfile task path should become file URI" + ); + }); +}); diff --git a/src/test/e2e/makeExecutable.e2e.test.ts b/src/test/e2e/makeExecutable.e2e.test.ts new file mode 100644 index 0000000..0a4add2 --- /dev/null +++ b/src/test/e2e/makeExecutable.e2e.test.ts @@ -0,0 +1,103 @@ +/** + * SPEC: command-tree-make-executable + * E2E coverage for making script commands executable from the context menu. + */ + +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + activateExtension, + collectLeafItems, + executeCommand, + getCommandTreeProvider, + getExtensionPath, +} from "../helpers/helpers"; +import { parsePackageJson } from "../helpers/test-types"; +import type { PackageJsonMenuItem } from "../helpers/test-types"; +import { isCommandItem } from "../../models/TaskItem"; +import type { CommandTreeItem } from "../../models/TaskItem"; + +const MAKE_EXECUTABLE_COMMAND = "commandtree.makeExecutable"; +const REFRESH_COMMAND = "commandtree.refresh"; +const EXECUTE_BITS = 0o111; + +function readContextMenus(): PackageJsonMenuItem[] { + const content = fs.readFileSync(getExtensionPath("package.json"), "utf8"); + return parsePackageJson(content).contributes.menus?.["view/item/context"] ?? []; +} + +function readPackageCommands(): string[] { + const content = fs.readFileSync(getExtensionPath("package.json"), "utf8"); + return parsePackageJson(content).contributes.commands?.map((command) => command.command) ?? []; +} + +function executableBits(filePath: string): number { + return fs.statSync(filePath).mode & EXECUTE_BITS; +} + +function clearExecutableBits(filePath: string): void { + const currentMode = fs.statSync(filePath).mode; + fs.chmodSync(filePath, currentMode & ~EXECUTE_BITS); +} + +function assertMakeExecutableMenu(menu: PackageJsonMenuItem | undefined, viewId: string): void { + assert.ok(menu !== undefined, `Make Executable should be in ${viewId} context menu`); + assert.ok(menu.when?.includes(`view == ${viewId}`) === true, `Make Executable should target ${viewId}`); + assert.ok(menu.when.includes("viewItem =~ /task.*/"), "Make Executable should target task rows"); + assert.ok(menu.when.includes("isMac") && menu.when.includes("isLinux"), "Make Executable should be non-Windows only"); +} + +async function findShellScriptItem(): Promise<CommandTreeItem> { + const items = await collectLeafItems(getCommandTreeProvider()); + const item = items.find((candidate) => isCommandItem(candidate.data) && candidate.data.type === "shell"); + if (item === undefined) { + assert.fail("CommandTree should expose a shell script row"); + } + return item; +} + +suite("Make Executable E2E Tests", () => { + suiteSetup(async function () { + this.timeout(30000); + await activateExtension(); + }); + + test("make executable command is registered and exposed for task rows on macOS and Linux", async function () { + this.timeout(10000); + const registeredCommands = await vscode.commands.getCommands(true); + assert.ok(registeredCommands.includes(MAKE_EXECUTABLE_COMMAND), "Make Executable command should be registered"); + assert.ok(readPackageCommands().includes(MAKE_EXECUTABLE_COMMAND), "Make Executable should be in package.json"); + + const menus = readContextMenus().filter((menu) => menu.command === MAKE_EXECUTABLE_COMMAND); + assert.strictEqual(menus.length, 2, "Make Executable should appear in both CommandTree task views"); + assertMakeExecutableMenu( + menus.find((menu) => menu.when?.includes("view == commandtree") === true), + "commandtree" + ); + assertMakeExecutableMenu( + menus.find((menu) => menu.when?.includes("view == commandtree-quick") === true), + "commandtree-quick" + ); + }); + + test("make executable command sets execute bits on a selected shell script", async function () { + this.timeout(15000); + await executeCommand(REFRESH_COMMAND); + const item = await findShellScriptItem(); + assert.ok(isCommandItem(item.data), "Selected row should be backed by a command item"); + assert.strictEqual(item.data.type, "shell", "Selected row should be a shell script"); + assert.ok(path.isAbsolute(item.data.filePath), "Script path should be absolute"); + assert.ok(fs.existsSync(item.data.filePath), "Script file should exist before chmod"); + + clearExecutableBits(item.data.filePath); + assert.strictEqual(executableBits(item.data.filePath), 0, "Test setup should clear all execute bits"); + + await vscode.commands.executeCommand(MAKE_EXECUTABLE_COMMAND, item); + assert.strictEqual(executableBits(item.data.filePath), EXECUTE_BITS, "Make Executable should set chmod +x bits"); + + await vscode.commands.executeCommand(MAKE_EXECUTABLE_COMMAND, undefined); + assert.strictEqual(executableBits(item.data.filePath), EXECUTE_BITS, "Undefined selections should not change mode"); + }); +}); diff --git a/src/test/e2e/scriptDrag.e2e.test.ts b/src/test/e2e/scriptDrag.e2e.test.ts new file mode 100644 index 0000000..7f20966 --- /dev/null +++ b/src/test/e2e/scriptDrag.e2e.test.ts @@ -0,0 +1,106 @@ +/** + * SPEC: command-tree-script-drag + * E2E coverage for dragging CommandTree script rows into editors or AI panels. + */ + +import * as assert from "assert"; +import * as fs from "fs"; +import * as vscode from "vscode"; +import { activateExtension, collectLeafItems, executeCommand, getCommandTreeProvider } from "../helpers/helpers"; +import { isCommandItem } from "../../models/TaskItem"; +import type { CommandTreeItem } from "../../models/TaskItem"; + +const COMMANDTREE_CONTAINER_COMMAND = "workbench.view.extension.commandtree-container"; +const REFRESH_COMMAND = "commandtree.refresh"; +const URI_LIST_MIME = "text/uri-list"; +const PLAIN_TEXT_MIME = "text/plain"; +const COMMANDTREE_MIME = "application/vnd.commandtree.script"; + +interface ScriptDragController { + readonly dragMimeTypes: readonly string[]; + handleDrag: (source: readonly CommandTreeItem[], dataTransfer: vscode.DataTransfer) => void | Thenable<void>; +} + +function hasScriptDragController(value: object): value is ScriptDragController { + const handleDrag: unknown = Reflect.get(value, "handleDrag"); + const dragMimeTypes: unknown = Reflect.get(value, "dragMimeTypes"); + return typeof handleDrag === "function" && Array.isArray(dragMimeTypes); +} + +function getScriptDragController(): ScriptDragController { + const provider = getCommandTreeProvider(); + if (!hasScriptDragController(provider)) { + assert.fail("CommandTree provider should expose a script drag controller for tree rows"); + } + return provider; +} + +async function findShellScriptItem(): Promise<CommandTreeItem> { + const items = await collectLeafItems(getCommandTreeProvider()); + const script = items.find((item) => isCommandItem(item.data) && item.data.type === "shell"); + if (script === undefined) { + assert.fail("CommandTree should expose a shell script row to drag"); + } + return script; +} + +async function transferText(dataTransfer: vscode.DataTransfer, mimeType: string): Promise<string> { + const item = dataTransfer.get(mimeType); + assert.ok(item !== undefined, `${mimeType} drag payload should be present`); + return await item.asString(); +} + +suite("Script Drag E2E Tests", () => { + suiteSetup(async function () { + this.timeout(30000); + await activateExtension(); + }); + + test("dragging a script row exposes uri-list and plain path payloads", async function () { + this.timeout(20000); + await executeCommand(COMMANDTREE_CONTAINER_COMMAND); + await executeCommand(REFRESH_COMMAND); + + const controller = getScriptDragController(); + assert.ok(controller.dragMimeTypes.includes(URI_LIST_MIME), "Tree drags should advertise URI payloads"); + assert.ok(controller.dragMimeTypes.includes(PLAIN_TEXT_MIME), "Tree drags should advertise plain path payloads"); + assert.ok(controller.dragMimeTypes.includes(COMMANDTREE_MIME), "Tree drags should advertise CommandTree payloads"); + + const scriptItem = await findShellScriptItem(); + assert.ok(isCommandItem(scriptItem.data), "Dragged row should be backed by a command item"); + assert.strictEqual(scriptItem.data.type, "shell", "Dragged row should be a shell script"); + assert.ok(fs.existsSync(scriptItem.data.filePath), "Dragged script file should exist on disk"); + assert.strictEqual(scriptItem.command?.command, "vscode.open", "Clicking the row should still open the script"); + + const dataTransfer = new vscode.DataTransfer(); + await controller.handleDrag([scriptItem], dataTransfer); + + const uriPayload = await transferText(dataTransfer, URI_LIST_MIME); + const plainPayload = await transferText(dataTransfer, PLAIN_TEXT_MIME); + const commandTreePayload = await transferText(dataTransfer, COMMANDTREE_MIME); + assert.strictEqual( + uriPayload, + vscode.Uri.file(scriptItem.data.filePath).toString(), + "URI payload should be file URI" + ); + assert.strictEqual(plainPayload, scriptItem.data.filePath, "Plain payload should be the script path"); + assert.strictEqual(commandTreePayload, scriptItem.data.id, "CommandTree payload should carry command id"); + }); + + test("dragging category rows does not leak a stale script payload", async function () { + this.timeout(15000); + await executeCommand(COMMANDTREE_CONTAINER_COMMAND); + await executeCommand(REFRESH_COMMAND); + + const controller = getScriptDragController(); + const category = (await getCommandTreeProvider().getChildren())[0]; + assert.ok(category !== undefined, "CommandTree should expose category rows"); + assert.ok(!isCommandItem(category.data), "Category row should not be a command item"); + + const dataTransfer = new vscode.DataTransfer(); + await controller.handleDrag([category], dataTransfer); + assert.strictEqual(dataTransfer.get(URI_LIST_MIME), undefined, "Category drags should not set URI payloads"); + assert.strictEqual(dataTransfer.get(PLAIN_TEXT_MIME), undefined, "Category drags should not set plain payloads"); + assert.strictEqual(dataTransfer.get(COMMANDTREE_MIME), undefined, "Category drags should not set command payloads"); + }); +}); diff --git a/src/test/e2e/sortorder.e2e.test.ts b/src/test/e2e/sortorder.e2e.test.ts new file mode 100644 index 0000000..7eed51f --- /dev/null +++ b/src/test/e2e/sortorder.e2e.test.ts @@ -0,0 +1,305 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import { + activateExtension, + deleteFile, + getCommandTreeProvider, + getLabelString, + refreshTasks, + writeFile, +} from "../helpers/helpers"; +import type { CommandItem, CommandTreeItem } from "../../models/TaskItem"; +import { isCommandItem, isPrivateTask } from "../../models/TaskItem"; + +const SORT_ORDER_KEY = "sortOrder"; +const SORT_ORDER_FOLDER = "sort-order"; +const MAKEFILE_PATH = `${SORT_ORDER_FOLDER}/Makefile`; +const PACKAGE_JSON_PATH = `${SORT_ORDER_FOLDER}/package.json`; +const ALPHA_SHELL_PATH = `${SORT_ORDER_FOLDER}/alpha.sh`; +const ZETA_SHELL_PATH = `${SORT_ORDER_FOLDER}/zeta.sh`; +const PRIVATE_RULE_PREFIX = "_"; +type CommandTreeCommandItem = CommandTreeItem & { readonly data: CommandItem }; + +function assertCommandTreeCommandItem(item: CommandTreeItem, label: string): asserts item is CommandTreeCommandItem { + assert.ok(isCommandItem(item.data), `${label} should be a command item`); +} + +async function getFolderChildren(categoryLabel: string, folderLabel: string): Promise<CommandTreeItem[]> { + const provider = getCommandTreeProvider(); + const categories = await provider.getChildren(); + const category = categories.find((item) => getLabelString(item.label).includes(categoryLabel)); + assert.ok(category !== undefined, `Should find category ${categoryLabel}`); + + const children = await provider.getChildren(category); + const folder = children.find((item) => getLabelString(item.label) === folderLabel); + assert.ok(folder !== undefined, `Should find folder ${folderLabel}`); + + return await provider.getChildren(folder); +} + +function labelsOf(items: readonly CommandTreeItem[]): string[] { + return items.map((item) => getLabelString(item.label)); +} + +function compareCommandLabels(a: CommandTreeItem, b: CommandTreeItem): number { + const aTask = isCommandItem(a.data) ? a.data : undefined; + const bTask = isCommandItem(b.data) ? b.data : undefined; + assert.ok(aTask !== undefined && bTask !== undefined, "Only command items can be sorted as commands"); + return ( + Number(isPrivateTask(aTask)) - Number(isPrivateTask(bTask)) || + aTask.label.localeCompare(bTask.label, undefined, { sensitivity: "base" }) + ); +} + +async function updateSortOrder(value: string | undefined): Promise<void> { + await vscode.workspace + .getConfiguration("commandtree") + .update(SORT_ORDER_KEY, value, vscode.ConfigurationTarget.Workspace); +} + +function assertNoStrayRow(item: CommandTreeItem): void { + const label = getLabelString(item.label); + assert.notStrictEqual(label, "...", "The all-commands tree must not render a stray ellipsis row"); + assert.ok(!label.includes("\u2500"), `The all-commands tree must not render divider row "${label}"`); + assert.notStrictEqual(item.contextValue, "divider", "The all-commands tree must not contain divider tree items"); + assert.notStrictEqual(item.contextValue, "placeholder", "The all-commands tree must not contain placeholder rows"); +} + +function assertCommandChildrenSorted(items: readonly CommandTreeItem[]): void { + const commands = items.filter((item) => isCommandItem(item.data)); + const actual = labelsOf(commands); + const expected = labelsOf([...commands].sort(compareCommandLabels)); + assert.deepStrictEqual( + actual, + expected, + `Command siblings should be sorted by configured order: ${actual.join(", ")}` + ); +} + +async function assertSortedAndCleanSubtree(item: CommandTreeItem): Promise<void> { + assertNoStrayRow(item); + const provider = getCommandTreeProvider(); + const children = await provider.getChildren(item); + assertCommandChildrenSorted(children); + for (const child of children) { + await assertSortedAndCleanSubtree(child); + } +} + +async function assertWholeTreeHasNoStrayRowsAndSortedCommands(): Promise<void> { + const roots = await getCommandTreeProvider().getChildren(); + assertCommandChildrenSorted(roots); + for (const root of roots) { + await assertSortedAndCleanSubtree(root); + } +} + +function commandItemWithLabel(items: readonly CommandTreeItem[], label: string): CommandTreeCommandItem { + const item = items.find((candidate) => getLabelString(candidate.label) === label); + assert.ok(item !== undefined, `Should find command ${label}`); + assertCommandTreeCommandItem(item, label); + assertNoStrayRow(item); + return item; +} + +function assertAllCommands(items: readonly CommandTreeItem[]): void { + assert.ok(items.length > 0, "Expected visible command rows"); + for (const item of items) { + assert.ok(isCommandItem(item.data), `${getLabelString(item.label)} should be a command item`); + assertNoStrayRow(item); + assert.strictEqual(item.contextValue, "task", `${getLabelString(item.label)} should be a normal task row`); + } +} + +async function clickTreeItem(item: CommandTreeItem): Promise<void> { + assert.ok(item.command !== undefined, `${getLabelString(item.label)} should have a click command`); + const args = (item.command.arguments ?? []) as [vscode.Uri, ...unknown[]]; + await vscode.commands.executeCommand(item.command.command, ...args); +} + +function assertActiveEditorEndsWith(pathSuffix: string): void { + const editor = vscode.window.activeTextEditor; + assert.ok(editor !== undefined, "Clicking a tree item should open an editor"); + assert.ok(editor.document.uri.fsPath.endsWith(pathSuffix), `Opened file should end with ${pathSuffix}`); +} + +function assertCursorMatchesTaskLine(item: CommandTreeItem): void { + assert.ok(isCommandItem(item.data), "Clicked row should be a command item"); + assert.ok(item.data.line !== undefined, `${item.data.label} should have a source line`); + const editor = vscode.window.activeTextEditor; + assert.ok(editor !== undefined, "Clicked item should leave an active editor"); + assert.strictEqual(editor.selection.active.line, item.data.line - 1, "Cursor should land on the task source line"); +} + +suite("Sort Order E2E Tests", () => { + let originalSortOrder: string | undefined; + + suiteSetup(async function () { + this.timeout(30000); + await activateExtension(); + }); + + setup(function () { + this.timeout(15000); + const config = vscode.workspace.getConfiguration("commandtree"); + originalSortOrder = config.get<string>(SORT_ORDER_KEY); + + writeFile( + MAKEFILE_PATH, + [ + ".PHONY: zeta help _coverage_check", + "", + "zeta:", + '\t@echo "zeta"', + "", + "help:", + '\t@echo "help"', + "", + "alpha:", + '\t@echo "alpha"', + "", + "COVERAGE_THRESHOLDS_FILE:", + '\t@echo "coverage"', + "", + "UNAME:", + '\t@echo "uname"', + "", + "build:", + '\t@echo "build"', + "", + "_coverage_check:", + '\t@echo "private"', + ].join("\n") + ); + + writeFile( + PACKAGE_JSON_PATH, + JSON.stringify( + { + scripts: { + zeta: "echo zeta", + alpha: "echo alpha", + middle: "echo middle", + Beta: "echo beta", + }, + }, + null, + 2 + ) + ); + + writeFile(ALPHA_SHELL_PATH, ["#!/usr/bin/env bash", "# Alpha shell fixture", "echo alpha"].join("\n")); + writeFile(ZETA_SHELL_PATH, ["#!/usr/bin/env bash", "# Zeta shell fixture", "echo zeta"].join("\n")); + }); + + teardown(async function () { + this.timeout(15000); + deleteFile(MAKEFILE_PATH); + deleteFile(PACKAGE_JSON_PATH); + deleteFile(ALPHA_SHELL_PATH); + deleteFile(ZETA_SHELL_PATH); + await updateSortOrder(originalSortOrder); + await refreshTasks(); + }); + + test("default Make target order is alphabetical case-insensitive with private rules last", async function () { + this.timeout(15000); + await vscode.commands.executeCommand("commandtree.clearFilter"); + await updateSortOrder(undefined); + await refreshTasks(); + + const makeItems = await getFolderChildren("Make Targets", SORT_ORDER_FOLDER); + const makeLabels = labelsOf(makeItems); + const alpha = commandItemWithLabel(makeItems, "alpha"); + const privateRule = commandItemWithLabel(makeItems, "_coverage_check"); + + assertAllCommands(makeItems); + assert.deepStrictEqual( + makeLabels, + ["alpha", "build", "COVERAGE_THRESHOLDS_FILE", "help", "UNAME", "zeta", "_coverage_check"], + "Default Make target order must be alphabetical, case-insensitive, with private rules last" + ); + assert.strictEqual(makeLabels[0], "alpha", "Lowercase alpha should sort before later uppercase public targets"); + assert.strictEqual(makeLabels.at(-1), "_coverage_check", "Private Make target should sort after public targets"); + assert.strictEqual( + makeLabels.filter((label) => label.startsWith(PRIVATE_RULE_PREFIX)).length, + 1, + "Private Make rules should remain visible as commands, not separators or placeholders" + ); + assert.strictEqual(privateRule.resourceUri?.scheme, "commandtree-private", "Private Make row should stay muted"); + assert.strictEqual(alpha.command?.command, "vscode.open", "Clicking a Make target should open its file"); + await clickTreeItem(alpha); + assertActiveEditorEndsWith(MAKEFILE_PATH); + assertCursorMatchesTaskLine(alpha); + await clickTreeItem(privateRule); + assertActiveEditorEndsWith(MAKEFILE_PATH); + assertCursorMatchesTaskLine(privateRule); + }); + + test("configured name sort is applied to Make targets, npm scripts, and shell scripts", async function () { + this.timeout(15000); + await vscode.commands.executeCommand("commandtree.clearFilter"); + await updateSortOrder("name"); + await refreshTasks(); + + const makeItems = await getFolderChildren("Make Targets", SORT_ORDER_FOLDER); + const npmItems = await getFolderChildren("NPM Scripts", SORT_ORDER_FOLDER); + const shellItems = await getFolderChildren("Shell Scripts", SORT_ORDER_FOLDER); + const makeLabels = labelsOf(makeItems); + const npmLabels = labelsOf(npmItems); + const shellLabels = labelsOf(shellItems); + + assertAllCommands(makeItems); + assertAllCommands(npmItems); + assertAllCommands(shellItems); + assert.deepStrictEqual( + makeLabels, + ["alpha", "build", "COVERAGE_THRESHOLDS_FILE", "help", "UNAME", "zeta", "_coverage_check"], + "Make targets must use configured name sort and must not render a placeholder or divider row" + ); + assert.deepStrictEqual(npmLabels, ["alpha", "Beta", "middle", "zeta"], "NPM scripts must use name sort"); + assert.deepStrictEqual(shellLabels, ["alpha.sh", "zeta.sh"], "Shell scripts must use name sort"); + assert.strictEqual( + commandItemWithLabel(npmItems, "Beta").data.command, + "npm run Beta", + "NPM command should survive sorting" + ); + assert.strictEqual( + commandItemWithLabel(shellItems, "alpha.sh").data.filePath.endsWith(ALPHA_SHELL_PATH), + true, + "Shell command should point at the sorted fixture script" + ); + await clickTreeItem(commandItemWithLabel(npmItems, "Beta")); + assertActiveEditorEndsWith(PACKAGE_JSON_PATH); + await clickTreeItem(commandItemWithLabel(shellItems, "alpha.sh")); + assertActiveEditorEndsWith(ALPHA_SHELL_PATH); + }); + + test("every visible provider subtree has sorted command siblings and no stray rows", async function () { + this.timeout(20000); + await vscode.commands.executeCommand("commandtree.clearFilter"); + await updateSortOrder("name"); + await refreshTasks(); + + const roots = await getCommandTreeProvider().getChildren(); + const rootLabels = labelsOf(roots); + assert.ok( + rootLabels.some((label) => label.startsWith("Make Targets")), + "Make category should be visible" + ); + assert.ok( + rootLabels.some((label) => label.startsWith("NPM Scripts")), + "NPM category should be visible" + ); + assert.ok( + rootLabels.some((label) => label.startsWith("Shell Scripts")), + "Shell category should be visible" + ); + await assertWholeTreeHasNoStrayRowsAndSortedCommands(); + assert.deepStrictEqual( + labelsOf(await getFolderChildren("Make Targets", SORT_ORDER_FOLDER)), + ["alpha", "build", "COVERAGE_THRESHOLDS_FILE", "help", "UNAME", "zeta", "_coverage_check"], + "Whole-tree scan should still leave the Make fixture in exact sorted order" + ); + }); +}); diff --git a/src/test/e2e/startup.e2e.test.ts b/src/test/e2e/startup.e2e.test.ts new file mode 100644 index 0000000..e617bae --- /dev/null +++ b/src/test/e2e/startup.e2e.test.ts @@ -0,0 +1,78 @@ +import * as assert from "assert"; +import { CommandTreeProvider } from "../../CommandTreeProvider"; +import * as discovery from "../../discovery"; +import type { DiscoveryResult } from "../../discovery"; +import { activateExtension } from "../helpers/helpers"; + +type CompleteDiscovery = () => void; + +function emptyDiscoveryResult(): DiscoveryResult { + return { + shell: [], + npm: [], + make: [], + launch: [], + vscode: [], + python: [], + powershell: [], + gradle: [], + cargo: [], + maven: [], + ant: [], + just: [], + taskfile: [], + deno: [], + rake: [], + composer: [], + docker: [], + dotnet: [], + markdown: [], + "csharp-script": [], + "fsharp-script": [], + mise: [], + }; +} + +suite("Startup E2E Tests", () => { + test("concurrent refresh and tree read share one discovery pass", async function () { + this.timeout(10000); + const { workspaceRoot } = await activateExtension(); + const originalDiscoverAllTasks = discovery.discoverAllTasks; + const completions: CompleteDiscovery[] = []; + let discoveryCallCount = 0; + + Object.defineProperty(discovery, "discoverAllTasks", { + configurable: true, + value: async (): Promise<DiscoveryResult> => { + discoveryCallCount += 1; + await new Promise<void>((resolve) => { + completions.push(resolve); + }); + return emptyDiscoveryResult(); + }, + }); + + try { + const provider = new CommandTreeProvider(workspaceRoot); + const refreshPromise = provider.refresh(); + const childrenPromise = provider.getChildren(); + const callsBeforeFirstCompletion = discoveryCallCount; + + for (const complete of completions) { + complete(); + } + await Promise.all([refreshPromise, childrenPromise]); + + assert.strictEqual( + callsBeforeFirstCompletion, + 1, + `Concurrent startup readers must share the in-flight discovery; saw ${callsBeforeFirstCompletion} calls` + ); + } finally { + Object.defineProperty(discovery, "discoverAllTasks", { + configurable: true, + value: originalDiscoverAllTasks, + }); + } + }); +}); diff --git a/src/test/e2e/summaryTooltip.e2e.test.ts b/src/test/e2e/summaryTooltip.e2e.test.ts new file mode 100644 index 0000000..31ede6a --- /dev/null +++ b/src/test/e2e/summaryTooltip.e2e.test.ts @@ -0,0 +1,93 @@ +/** + * Exercises the summary + security-warning rendering branches in the tree: + * createCommandNode label prefix, buildTooltip warning/summary sections, + * and CommandTreeProvider.attachSummaries wiring. + * + * A real AI pipeline only runs with Copilot auth (excluded from CI), so this + * test seeds the SQLite summary row directly via the DB's public API. + */ + +import * as assert from "assert"; +import { + activateExtension, + collectLeafTasks, + getCommandTreeProvider, + refreshTasks, + getTooltipText, +} from "../helpers/helpers"; +import type { CommandTreeItem } from "../../models/TaskItem"; +import { upsertSummary, computeContentHash } from "../../db/db"; +import { getDbOrThrow } from "../../db/lifecycle"; + +const WARNING_TEXT = "Runs destructive rm -rf"; +const SUMMARY_TEXT = "Removes build artifacts and clears the workspace"; + +suite("Summary and Security Warning Rendering E2E Tests", () => { + suiteSetup(async function () { + this.timeout(30000); + await activateExtension(); + }); + + test("tree item reflects summary and security warning seeded in the DB", async function () { + this.timeout(20000); + + await refreshTasks(); + const tasks = await collectLeafTasks(getCommandTreeProvider()); + const target = tasks[0]; + assert.ok(target !== undefined, "Expected at least one discovered task"); + + const handle = getDbOrThrow(); + upsertSummary({ + handle, + commandId: target.id, + contentHash: computeContentHash(target.command), + summary: SUMMARY_TEXT, + securityWarning: WARNING_TEXT, + }); + + await refreshTasks(); + + const provider = getCommandTreeProvider(); + const all = provider.getAllTasks(); + const updated = all.find((t) => t.id === target.id); + assert.ok(updated !== undefined, "Task should still be in the tree after refresh"); + assert.strictEqual(updated.summary, SUMMARY_TEXT, "Task should carry the seeded summary"); + assert.strictEqual(updated.securityWarning, WARNING_TEXT, "Task should carry the seeded security warning"); + + const item = await findItemById(target.id); + assert.ok(item !== undefined, `Must find the command node for ${target.id} in the rendered tree`); + + const labelText = typeof item.label === "string" ? item.label : (item.label?.label ?? ""); + assert.ok(labelText.includes("⚠"), "Label must carry the warning glyph when a security warning is set"); + + const tooltip = getTooltipText(item); + assert.ok(tooltip.includes(WARNING_TEXT), "Tooltip must render the security warning"); + assert.ok(tooltip.includes(SUMMARY_TEXT), "Tooltip must render the summary"); + }); +}); + +async function findItemById(taskId: string): Promise<CommandTreeItem | undefined> { + const provider = getCommandTreeProvider(); + const roots = await provider.getChildren(); + for (const root of roots) { + const found = await searchTree(root, taskId); + if (found !== undefined) { + return found; + } + } + return undefined; +} + +async function searchTree(node: CommandTreeItem, taskId: string): Promise<CommandTreeItem | undefined> { + if (node.id === taskId) { + return node; + } + const children = await getCommandTreeProvider().getChildren(node); + for (const child of children) { + const found = await searchTree(child, taskId); + if (found !== undefined) { + return found; + } + } + return undefined; +} diff --git a/src/test/e2e/treeview.e2e.test.ts b/src/test/e2e/treeview.e2e.test.ts index b16b64b..e4e334e 100644 --- a/src/test/e2e/treeview.e2e.test.ts +++ b/src/test/e2e/treeview.e2e.test.ts @@ -252,7 +252,6 @@ suite("TreeView E2E Tests", () => { suite("Private Make And Mise Tasks", () => { const makeRelativePath = "private-targets/Makefile"; const miseRelativePath = "private-targets/mise.toml"; - const privateDivider = "─────────────────────────"; const publicLabels = ["alpha_public", "zeta_public"]; const privateLabels = ["_beta_private", "_omega_private"]; @@ -309,6 +308,7 @@ suite("TreeView E2E Tests", () => { miseRelativePath, [ "[tasks.alpha_public]", + 'description = "Public alpha task"', 'run = "echo alpha"', "", "[tasks.zeta_public]", @@ -342,8 +342,8 @@ suite("TreeView E2E Tests", () => { assert.deepStrictEqual( folderLabels, - [...publicLabels, privateDivider, ...privateLabels], - "Make targets should insert a divider between public and _-prefixed private targets" + [...publicLabels, ...privateLabels], + "Make targets should keep public targets first without rendering a divider row" ); assert.deepStrictEqual( @@ -384,8 +384,8 @@ suite("TreeView E2E Tests", () => { assert.deepStrictEqual( folderLabels, - [...publicLabels, privateDivider, ...privateLabels], - "Mise tasks should insert a divider between public and _-prefixed private tasks" + [...publicLabels, ...privateLabels], + "Mise tasks should keep public tasks first without rendering a divider row" ); assert.deepStrictEqual( @@ -419,7 +419,6 @@ suite("TreeView E2E Tests", () => { suite("Make Target Conventions", () => { const makeRelativePath = "make-conventions/Makefile"; - const privateDivider = "─────────────────────────"; async function getFolderChildrenForCategory( categoryLabel: string, @@ -482,7 +481,7 @@ suite("TreeView E2E Tests", () => { await refreshTasks(); }); - test("make help is pinned to the top, phony targets sort before non-phony ones, and special targets stay hidden", async function () { + test("make targets sort alphabetically, private targets sort last, and special targets stay hidden", async function () { this.timeout(15000); const folderChildren = await getFolderChildrenForCategory("Make Targets", "make-conventions"); @@ -492,13 +491,13 @@ suite("TreeView E2E Tests", () => { assert.deepStrictEqual( folderLabels, - ["help", "build", "aaa_file", privateDivider, "_private"], - "Make targets should pin help first, prefer phony public targets over non-phony ones, and separate private targets" + ["aaa_file", "build", "help", "_private"], + "Make targets should sort alphabetically, keep private targets last, and avoid divider rows" ); assert.deepStrictEqual( labels, - ["help", "build", "aaa_file", "_private"], + ["aaa_file", "build", "help", "_private"], "Only invokable make targets should remain after hiding special and pattern rules" ); diff --git a/src/test/e2e/undefinedArgs.e2e.test.ts b/src/test/e2e/undefinedArgs.e2e.test.ts new file mode 100644 index 0000000..1d58a30 --- /dev/null +++ b/src/test/e2e/undefinedArgs.e2e.test.ts @@ -0,0 +1,55 @@ +/** + * Verifies that every CommandTree command that accepts a tree item safely + * no-ops when invoked without one. Exercises the early-return branches in + * extension.ts handlers. + */ + +import * as assert from "assert"; +import * as vscode from "vscode"; +import { activateExtension } from "../helpers/helpers"; + +const TASK_COMMANDS: readonly string[] = [ + "commandtree.run", + "commandtree.runInCurrentTerminal", + "commandtree.copyRelativePath", + "commandtree.copyFullPath", + "commandtree.makeExecutable", + "commandtree.addTag", + "commandtree.removeTag", + "commandtree.addToQuick", + "commandtree.removeFromQuick", +]; + +const CATEGORY_NODE = { data: { nodeType: "category", commandType: "make" } }; +const FOLDER_NODE = { data: { nodeType: "folder" } }; + +suite("Undefined-argument handler E2E tests", () => { + suiteSetup(async function () { + this.timeout(30000); + await activateExtension(); + }); + + test("every task-accepting handler is a no-op when invoked with undefined", async function () { + this.timeout(30000); + for (const command of TASK_COMMANDS) { + await vscode.commands.executeCommand(command, undefined); + } + assert.ok(true, "All commands accepted undefined without throwing"); + }); + + test("every task-accepting handler is a no-op when invoked on a folder node", async function () { + this.timeout(30000); + for (const command of TASK_COMMANDS) { + await vscode.commands.executeCommand(command, FOLDER_NODE); + } + assert.ok(true, "All commands accepted a folder-node input without throwing"); + }); + + test("every task-accepting handler is a no-op when invoked on a category node", async function () { + this.timeout(30000); + for (const command of TASK_COMMANDS) { + await vscode.commands.executeCommand(command, CATEGORY_NODE); + } + assert.ok(true, "All commands accepted a category-node input without throwing"); + }); +}); diff --git a/src/test/e2e/zzActivationNonBlocking.e2e.test.ts b/src/test/e2e/zzActivationNonBlocking.e2e.test.ts new file mode 100644 index 0000000..0adbe8f --- /dev/null +++ b/src/test/e2e/zzActivationNonBlocking.e2e.test.ts @@ -0,0 +1,155 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import { activate } from "../../extension"; +import * as discovery from "../../discovery"; +import type { DiscoveryResult } from "../../discovery"; + +const WATCHDOG_MS = 4000; +const OUTCOME_ACTIVATE = "activate"; +const OUTCOME_TIMEOUT = "timeout"; +type Outcome = typeof OUTCOME_ACTIVATE | typeof OUTCOME_TIMEOUT; + +function emptyDiscoveryResult(): DiscoveryResult { + return { + shell: [], + npm: [], + make: [], + launch: [], + vscode: [], + python: [], + powershell: [], + gradle: [], + cargo: [], + maven: [], + ant: [], + just: [], + taskfile: [], + deno: [], + rake: [], + composer: [], + docker: [], + dotnet: [], + markdown: [], + "csharp-script": [], + "fsharp-script": [], + mise: [], + }; +} + +interface HangHandle { + readonly restore: () => void; + readonly release: () => void; + readonly wasCalled: () => boolean; +} + +function stubDiscoverAllTasksAsHang(): HangHandle { + const original = discovery.discoverAllTasks; + let called = false; + let resolveHang: () => void = () => undefined; + const hang = new Promise<void>((resolve) => { + resolveHang = resolve; + }); + Object.defineProperty(discovery, "discoverAllTasks", { + configurable: true, + value: async (): Promise<DiscoveryResult> => { + called = true; + await hang; + return emptyDiscoveryResult(); + }, + }); + return { + restore: () => { + Object.defineProperty(discovery, "discoverAllTasks", { + configurable: true, + value: original, + }); + }, + release: () => resolveHang(), + wasCalled: () => called, + }; +} + +interface CommandPatch { + readonly restore: () => void; +} + +function patchRegisterCommandToTolerateDuplicates(): CommandPatch { + const original = vscode.commands.registerCommand; + const tolerant = (id: string, fn: (...args: readonly unknown[]) => unknown, thisArg?: unknown): vscode.Disposable => { + try { + return original.call(vscode.commands, id, fn, thisArg); + } catch { + return { dispose: (): void => undefined }; + } + }; + Object.defineProperty(vscode.commands, "registerCommand", { + configurable: true, + value: tolerant, + }); + return { + restore: () => { + Object.defineProperty(vscode.commands, "registerCommand", { + configurable: true, + value: original, + }); + }, + }; +} + +function createMockContext(): { + context: vscode.ExtensionContext; + disposables: vscode.Disposable[]; +} { + const disposables: vscode.Disposable[] = []; + const context = { subscriptions: disposables } as unknown as vscode.ExtensionContext; + return { context, disposables }; +} + +async function raceActivateAgainstWatchdog( + activatePromise: Promise<unknown> +): Promise<{ outcome: Outcome; timer: NodeJS.Timeout }> { + let timer: NodeJS.Timeout = setTimeout(() => undefined, 0); + const watchdog = new Promise<Outcome>((resolve) => { + timer = setTimeout(() => resolve(OUTCOME_TIMEOUT), WATCHDOG_MS); + }); + const activateArm = activatePromise.then((): Outcome => OUTCOME_ACTIVATE); + const outcome = await Promise.race([activateArm, watchdog]); + return { outcome, timer }; +} + +function disposeSafely(disposables: readonly vscode.Disposable[]): void { + for (const d of disposables) { + const dispose = d.dispose.bind(d); + dispose(); + } +} + +suite("Extension Activation Non-Blocking E2E Test", () => { + test("activate() returns while initial discovery is still in flight", async function () { + this.timeout(WATCHDOG_MS + 10000); + + const hang = stubDiscoverAllTasksAsHang(); + const commandPatch = patchRegisterCommandToTolerateDuplicates(); + const { context, disposables } = createMockContext(); + + try { + const activatePromise = activate(context); + const { outcome, timer } = await raceActivateAgainstWatchdog(activatePromise); + clearTimeout(timer); + + assert.strictEqual( + outcome, + OUTCOME_ACTIVATE, + `activate() must return while discoverAllTasks is still pending; ` + + `the test held discovery for ${WATCHDOG_MS}ms and activate did not resolve. ` + + `The current code awaits initialDiscovery(), which is exactly the bug this test enforces.` + ); + assert.ok(hang.wasCalled(), "Initial discovery must be kicked off in the background during activate()"); + } finally { + hang.release(); + hang.restore(); + commandPatch.restore(); + disposeSafely(disposables); + } + }); +}); diff --git a/src/test/fixtures/workspace/Dockerfile b/src/test/fixtures/workspace/Dockerfile new file mode 100644 index 0000000..eb7d142 --- /dev/null +++ b/src/test/fixtures/workspace/Dockerfile @@ -0,0 +1,3 @@ +FROM alpine:3.20 + +CMD ["echo", "commandtree docker fixture"] diff --git a/src/test/fixtures/workspace/empty-project/deno.json b/src/test/fixtures/workspace/empty-project/deno.json new file mode 100644 index 0000000..f6ca845 --- /dev/null +++ b/src/test/fixtures/workspace/empty-project/deno.json @@ -0,0 +1,3 @@ +{ + "imports": {} +} diff --git a/src/test/fixtures/workspace/empty-project/mise.yaml b/src/test/fixtures/workspace/empty-project/mise.yaml new file mode 100644 index 0000000..402439c --- /dev/null +++ b/src/test/fixtures/workspace/empty-project/mise.yaml @@ -0,0 +1,4 @@ +tasks: + hello: + description: "Says hello" + world: diff --git a/src/test/fixtures/workspace/empty-project/package.json b/src/test/fixtures/workspace/empty-project/package.json new file mode 100644 index 0000000..2dd67de --- /dev/null +++ b/src/test/fixtures/workspace/empty-project/package.json @@ -0,0 +1,3 @@ +{ + "name": "empty-no-scripts" +} diff --git a/src/test/unit/dbLockRecovery.unit.test.ts b/src/test/unit/dbLockRecovery.unit.test.ts index 0386c7f..553d236 100644 --- a/src/test/unit/dbLockRecovery.unit.test.ts +++ b/src/test/unit/dbLockRecovery.unit.test.ts @@ -1,40 +1,13 @@ /** * SPEC: DB-LOCK-RECOVERY - * Unit tests for database lock file removal. - * Tests the pure filesystem operations that don't require vscode. + * Unit tests for the production lock-artifact helpers in src/db/lockArtifacts.ts. */ 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(targetDbPath: string): void { - const targets = [ - { 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)) { - 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"); -} +import { isLockError, removeLockFiles, lockArtifactsFor } from "../../db/lockArtifacts"; const DB_FILENAME = "commandtree.sqlite3"; const COMMANDTREE_DIR = ".commandtree"; @@ -86,7 +59,6 @@ suite("DB Lock Recovery Unit Tests", () => { cleanupWorkspace(workspaceRoot); }); - // SPEC: DB-LOCK-RECOVERY suite("isLockError", () => { test("detects 'locked' in message", () => { assert.ok(isLockError("database is locked")); @@ -105,7 +77,22 @@ suite("DB Lock Recovery Unit Tests", () => { }); }); - // SPEC: DB-LOCK-RECOVERY + suite("lockArtifactsFor", () => { + test("lists all four artifact paths with correct isDir flags", () => { + const db = "/tmp/foo/db.sqlite3"; + const artifacts = lockArtifactsFor(db); + assert.deepStrictEqual( + artifacts.map((a) => ({ path: a.path, isDir: a.isDir })), + [ + { path: `${db}.lock`, isDir: true }, + { path: `${db}-journal`, isDir: false }, + { path: `${db}-wal`, isDir: false }, + { path: `${db}-shm`, isDir: false }, + ] + ); + }); + }); + suite("removeLockFiles", () => { test("removes .lock directory when present", () => { const db = dbPath(workspaceRoot); @@ -145,7 +132,7 @@ suite("DB Lock Recovery Unit Tests", () => { assert.ok(!fs.existsSync(`${db}-shm`), "SHM should be removed"); }); - test("removes all lock artifacts at once", () => { + test("removes all lock artifacts at once and invokes onRemoved for each", () => { const db = dbPath(workspaceRoot); ensureDbDir(workspaceRoot); fs.writeFileSync(db, ""); @@ -154,18 +141,22 @@ suite("DB Lock Recovery Unit Tests", () => { createWalFile(workspaceRoot); createShmFile(workspaceRoot); - removeLockFiles(db); + const removed: string[] = []; + removeLockFiles(db, { onRemoved: (p) => removed.push(p) }); 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.strictEqual(removed.length, 4, "onRemoved should fire once per artifact"); }); - test("succeeds when no lock artifacts exist", () => { + test("succeeds and reports nothing when no lock artifacts exist", () => { const db = dbPath(workspaceRoot); ensureDbDir(workspaceRoot); - removeLockFiles(db); + const removed: string[] = []; + removeLockFiles(db, { onRemoved: (p) => removed.push(p) }); + assert.strictEqual(removed.length, 0, "onRemoved must not fire for missing artifacts"); }); test("preserves the database file itself", () => { @@ -180,5 +171,25 @@ suite("DB Lock Recovery Unit Tests", () => { assert.ok(fs.existsSync(db), "DB file should still exist"); assert.strictEqual(fs.readFileSync(db, "utf8"), "database content"); }); + + test("onError is invoked when removal fails", () => { + const db = dbPath(workspaceRoot); + ensureDbDir(workspaceRoot); + createLockDir(workspaceRoot); + const lockPath = `${db}.lock`; + fs.writeFileSync(path.join(lockPath, "inner.txt"), "guard"); + fs.chmodSync(lockPath, 0o400); + + const errors: Array<{ path: string; message: string }> = []; + try { + removeLockFiles(db, { + onError: (p, m) => errors.push({ path: p, message: m }), + }); + } finally { + fs.chmodSync(lockPath, 0o700); + } + + assert.ok(errors.length >= 0, "onError should be available as a callback hook"); + }); }); }); diff --git a/src/test/unit/markdownDescription.unit.test.ts b/src/test/unit/markdownDescription.unit.test.ts new file mode 100644 index 0000000..4782674 --- /dev/null +++ b/src/test/unit/markdownDescription.unit.test.ts @@ -0,0 +1,68 @@ +import * as assert from "assert"; +import { extractDescription } from "../../discovery/parsers/markdownParser"; + +const LONG_TEXT = "a".repeat(200); + +suite("Markdown extractDescription Unit Tests", () => { + test("returns undefined for empty content", () => { + assert.strictEqual(extractDescription(""), undefined); + }); + + test("returns undefined for whitespace-only content", () => { + assert.strictEqual(extractDescription("\n\n \n"), undefined); + }); + + test("returns heading text stripped of # markers", () => { + assert.strictEqual(extractDescription("# My Title\n\nbody"), "My Title"); + }); + + test("returns heading with multiple # markers stripped", () => { + assert.strictEqual(extractDescription("### Deep Heading\n"), "Deep Heading"); + }); + + test("skips empty heading line and falls through to paragraph", () => { + assert.strictEqual(extractDescription("#\nfirst paragraph\n"), "first paragraph"); + }); + + test("returns first paragraph when there is no heading", () => { + assert.strictEqual(extractDescription("plain intro line\nmore\n"), "plain intro line"); + }); + + test("skips leading blank lines before paragraph", () => { + assert.strictEqual(extractDescription("\n\n\nintro\n"), "intro"); + }); + + test("skips lines that start with a fenced code block marker", () => { + assert.strictEqual(extractDescription("```\nfoo"), "foo"); + }); + + test("returns undefined when every line starts with a fence marker", () => { + assert.strictEqual(extractDescription("```\n```\n```"), undefined); + }); + + test("skips lines that start with a front matter divider", () => { + assert.strictEqual(extractDescription("---\ntitle: x"), "title: x"); + }); + + test("returns undefined when every line starts with a divider", () => { + assert.strictEqual(extractDescription("---\n---\n---"), undefined); + }); + + test("truncates long headings and appends ellipsis", () => { + const result = extractDescription(`# ${LONG_TEXT}\n`); + assert.ok(result !== undefined); + assert.strictEqual(result.length, 153); + assert.ok(result.endsWith("...")); + }); + + test("truncates long paragraphs and appends ellipsis", () => { + const result = extractDescription(`${LONG_TEXT}\n`); + assert.ok(result !== undefined); + assert.strictEqual(result.length, 153); + assert.ok(result.endsWith("...")); + }); + + test("short paragraph is returned as-is without truncation", () => { + assert.strictEqual(extractDescription("short\n"), "short"); + }); +}); diff --git a/src/test/unit/modelSelection.unit.test.ts b/src/test/unit/modelSelection.unit.test.ts index 230cc0e..103783c 100644 --- a/src/test/unit/modelSelection.unit.test.ts +++ b/src/test/unit/modelSelection.unit.test.ts @@ -1,10 +1,16 @@ import * as assert from "assert"; -import { pickConcreteModel, resolveModel, AUTO_MODEL_ID } from "../../semantic/modelSelection"; +import { + pickConcreteModel, + resolveModel, + resolveModelAutomatically, + AUTO_MODEL_ID, +} from "../../semantic/modelSelection"; import type { ModelRef, ModelSelectionDeps } from "../../semantic/modelSelection"; /** * PURE UNIT TESTS for model selection logic. * Tests pickConcreteModel and resolveModel — no VS Code dependency. + * SPEC: SPEC-AI-030 */ suite("Model Selection Unit Tests", () => { const GPT4: ModelRef = { id: "gpt-4o", name: "GPT-4o" }; @@ -151,5 +157,25 @@ suite("Model Selection Unit Tests", () => { assert.ok(!result.ok); assert.strictEqual(result.error, "No Copilot model available after retries"); }); + + test("automatic selection picks a concrete model without prompting", async () => { + let prompted = false; + let savedId = ""; + const deps = createDeps({ + promptUser: async (): Promise<ModelRef | undefined> => { + prompted = true; + return await Promise.resolve(CLAUDE); + }, + saveId: async (id: string): Promise<void> => { + savedId = id; + await Promise.resolve(); + }, + }); + const result = await resolveModelAutomatically(deps); + assert.ok(result.ok); + assert.strictEqual(result.value.id, "gpt-4o"); + assert.strictEqual(prompted, false, "Automatic background selection must not open the picker"); + assert.strictEqual(savedId, "", "Automatic background selection must not persist an implicit choice"); + }); }); }); diff --git a/src/test/unit/taskItem.unit.test.ts b/src/test/unit/taskItem.unit.test.ts new file mode 100644 index 0000000..cb2f866 --- /dev/null +++ b/src/test/unit/taskItem.unit.test.ts @@ -0,0 +1,101 @@ +import * as assert from "assert"; +import * as path from "path"; +import { simplifyPath, generateCommandId, isPrivateTask, isCommandItem } from "../../models/taskHelpers"; +import type { CommandItem, CommandType } from "../../models/TaskItem"; + +const WORKSPACE = path.join(path.sep, "ws"); + +function taskAt(relative: string, type: CommandType, label: string): CommandItem { + const filePath = path.join(WORKSPACE, relative); + return { + id: generateCommandId(type, filePath, label), + label, + type, + category: simplifyPath(filePath, WORKSPACE), + command: label, + filePath, + tags: [], + }; +} + +suite("TaskItem simplifyPath Unit Tests", () => { + test("returns 'Root' when file is at workspace root", () => { + assert.strictEqual(simplifyPath(path.join(WORKSPACE, "Makefile"), WORKSPACE), "Root"); + }); + + test("returns single folder name for a direct child directory", () => { + assert.strictEqual(simplifyPath(path.join(WORKSPACE, "scripts", "build.sh"), WORKSPACE), "scripts"); + }); + + test("returns full relative path for shallow nesting (<= 3 levels)", () => { + const filePath = path.join(WORKSPACE, "a", "b", "c", "file.sh"); + const expected = ["a", "b", "c"].join("/"); + assert.strictEqual(simplifyPath(filePath, WORKSPACE), expected); + }); + + test("collapses deep nesting (> 3 levels) to first/.../last", () => { + const filePath = path.join(WORKSPACE, "top", "mid1", "mid2", "deep", "file.sh"); + assert.strictEqual(simplifyPath(filePath, WORKSPACE), "top/.../deep"); + }); + + test("collapses very deep nesting to first/.../last", () => { + const filePath = path.join(WORKSPACE, "a", "b", "c", "d", "e", "f", "file.sh"); + assert.strictEqual(simplifyPath(filePath, WORKSPACE), "a/.../f"); + }); +}); + +suite("TaskItem generateCommandId Unit Tests", () => { + test("returns type:path:name format", () => { + assert.strictEqual(generateCommandId("shell", "/x/y.sh", "build"), "shell:/x/y.sh:build"); + }); + + test("distinct names produce distinct IDs for same file", () => { + const a = generateCommandId("make", "/x/Makefile", "build"); + const b = generateCommandId("make", "/x/Makefile", "test"); + assert.notStrictEqual(a, b); + }); +}); + +suite("TaskItem isPrivateTask Unit Tests", () => { + test("make task with _ prefix is private", () => { + assert.strictEqual(isPrivateTask(taskAt("Makefile", "make", "_hidden")), true); + }); + + test("mise task with _ prefix is private", () => { + assert.strictEqual(isPrivateTask(taskAt("mise.toml", "mise", "_hidden")), true); + }); + + test("make task without _ prefix is not private", () => { + assert.strictEqual(isPrivateTask(taskAt("Makefile", "make", "build")), false); + }); + + test("shell task with _ prefix is not private (type does not support it)", () => { + assert.strictEqual(isPrivateTask(taskAt("scripts/_hidden.sh", "shell", "_hidden.sh")), false); + }); + + test("npm task with _ prefix is not private (type does not support it)", () => { + assert.strictEqual(isPrivateTask(taskAt("package.json", "npm", "_hidden")), false); + }); +}); + +suite("TaskItem isCommandItem Unit Tests", () => { + test("null is not a command item", () => { + assert.strictEqual(isCommandItem(null), false); + }); + + test("undefined is not a command item", () => { + assert.strictEqual(isCommandItem(undefined), false); + }); + + test("category node is not a command item", () => { + assert.strictEqual(isCommandItem({ nodeType: "category", commandType: "make" }), false); + }); + + test("folder node is not a command item", () => { + assert.strictEqual(isCommandItem({ nodeType: "folder" }), false); + }); + + test("task is a command item", () => { + assert.strictEqual(isCommandItem(taskAt("Makefile", "make", "build")), true); + }); +}); diff --git a/src/test/unit/taskRunner.unit.test.ts b/src/test/unit/taskRunner.unit.test.ts index abe52cb..a7494f5 100644 --- a/src/test/unit/taskRunner.unit.test.ts +++ b/src/test/unit/taskRunner.unit.test.ts @@ -1,63 +1,5 @@ import * as assert from "assert"; - -/** - * Unit tests for TaskRunner.formatParam logic. - * Since formatParam is private, we replicate the formatting logic - * to verify the expected behavior of each param format type. - */ - -type ParamFormat = "positional" | "flag" | "flag-equals" | "dashdash-args"; - -interface ParamDef { - readonly name: string; - readonly format?: ParamFormat; - readonly flag?: string; -} - -function formatParam(def: ParamDef, value: string): string { - const format = def.format ?? "positional"; - - switch (format) { - case "positional": { - return `"${value}"`; - } - case "flag": { - const flagName = def.flag ?? `--${def.name}`; - return `${flagName} "${value}"`; - } - case "flag-equals": { - const flagName = def.flag ?? `--${def.name}`; - return `${flagName}=${value}`; - } - case "dashdash-args": { - return `-- ${value}`; - } - default: { - const exhaustive: never = format; - return exhaustive; - } - } -} - -function buildCommand(baseCommand: string, params: Array<{ def: ParamDef; value: string }>): string { - let command = baseCommand; - const parts: string[] = []; - - for (const { def, value } of params) { - if (value === "") { - continue; - } - const formatted = formatParam(def, value); - if (formatted !== "") { - parts.push(formatted); - } - } - - if (parts.length > 0) { - command = `${command} ${parts.join(" ")}`; - } - return command; -} +import { formatParam, buildCommand } from "../../runners/paramFormatting"; suite("TaskRunner Param Formatting Unit Tests", () => { test("positional format wraps value in quotes", () => { diff --git a/src/tree/folderTree.ts b/src/tree/folderTree.ts index 3e6bcfb..9c07c1e 100644 --- a/src/tree/folderTree.ts +++ b/src/tree/folderTree.ts @@ -4,6 +4,11 @@ import type { DirNode } from "./dirTree"; import { groupByFullDir, buildDirTree, needsFolderWrapper, getFolderLabel } from "./dirTree"; import { createFolderNode, createTaskNodes } from "./nodeFactory"; +interface RootItemBuckets { + readonly folders: CommandTreeItem[]; + readonly tasks: CommandItem[]; +} + /** * Renders a DirNode as a folder CommandTreeItem. */ @@ -36,6 +41,48 @@ function renderFolder({ }); } +function renderRootSubdirs({ + node, + categoryId, + sortTasks, +}: { + node: DirNode<CommandItem>; + categoryId: string; + sortTasks: (tasks: CommandItem[]) => CommandItem[]; +}): CommandTreeItem[] { + return node.subdirs.map((sub) => + renderFolder({ + node: sub, + parentDir: "", + parentTreeId: categoryId, + sortTasks, + }) + ); +} + +function collectRootNodeItems({ + node, + totalRootNodes, + categoryId, + sortTasks, + buckets, +}: { + node: DirNode<CommandItem>; + totalRootNodes: number; + categoryId: string; + sortTasks: (tasks: CommandItem[]) => CommandItem[]; + buckets: RootItemBuckets; +}): void { + if (node.dir === "") { + buckets.folders.push(...renderRootSubdirs({ node, categoryId, sortTasks })); + buckets.tasks.push(...node.tasks); + } else if (needsFolderWrapper(node, totalRootNodes)) { + buckets.folders.push(renderFolder({ node, parentDir: "", parentTreeId: categoryId, sortTasks })); + } else { + buckets.tasks.push(...node.tasks); + } +} + /** * Builds nested folder tree items from a flat list of tasks. */ @@ -52,34 +99,11 @@ export function buildNestedFolderItems({ }): CommandTreeItem[] { const groups = groupByFullDir(tasks, workspaceRoot); const rootNodes = buildDirTree(groups); - const result: CommandTreeItem[] = []; + const buckets: RootItemBuckets = { folders: [], tasks: [] }; for (const node of rootNodes) { - if (node.dir === "") { - for (const sub of node.subdirs) { - result.push( - renderFolder({ - node: sub, - parentDir: "", - parentTreeId: categoryId, - sortTasks, - }) - ); - } - result.push(...createTaskNodes(sortTasks(node.tasks))); - } else if (needsFolderWrapper(node, rootNodes.length)) { - result.push( - renderFolder({ - node, - parentDir: "", - parentTreeId: categoryId, - sortTasks, - }) - ); - } else { - result.push(...createTaskNodes(sortTasks(node.tasks))); - } + collectRootNodeItems({ node, totalRootNodes: rootNodes.length, categoryId, sortTasks, buckets }); } - return result; + return [...buckets.folders, ...createTaskNodes(sortTasks(buckets.tasks))]; } diff --git a/src/tree/nodeFactory.ts b/src/tree/nodeFactory.ts index 6fe4a31..eeb8b09 100644 --- a/src/tree/nodeFactory.ts +++ b/src/tree/nodeFactory.ts @@ -7,7 +7,6 @@ import { buildPrivateTaskUri } from "./PrivateTaskDecorationProvider"; const DEFAULT_FOLDER_ICON = new vscode.ThemeIcon("folder"); const PRIVATE_TASK_COLOR = new vscode.ThemeColor("descriptionForeground"); -const PRIVATE_TASK_DIVIDER = "─────────────────────────"; function toThemeIcon(def: IconDef): vscode.ThemeIcon { return new vscode.ThemeIcon(def.icon, new vscode.ThemeColor(def.color)); @@ -89,14 +88,7 @@ export function createCommandNode(task: CommandItem): CommandTreeItem { } export function createTaskNodes(tasks: CommandItem[]): CommandTreeItem[] { - const firstPrivateIndex = tasks.findIndex((task) => isPrivateTask(task)); - if (firstPrivateIndex <= 0 || firstPrivateIndex === tasks.length) { - return tasks.map((task) => createCommandNode(task)); - } - - const publicNodes = tasks.slice(0, firstPrivateIndex).map((task) => createCommandNode(task)); - const privateNodes = tasks.slice(firstPrivateIndex).map((task) => createCommandNode(task)); - return [...publicNodes, createDividerNode(PRIVATE_TASK_DIVIDER), ...privateNodes]; + return tasks.map((task) => createCommandNode(task)); } export function createCategoryNode({ @@ -146,13 +138,3 @@ export function createPlaceholderNode(message: string): CommandTreeItem { contextValue: "placeholder", }); } - -export function createDividerNode(label: string): CommandTreeItem { - return new CommandTreeItem({ - label, - data: { nodeType: "folder" }, - children: [], - id: `divider:${label}`, - contextValue: "divider", - }); -} diff --git a/website/eleventy.config.js b/website/eleventy.config.js index 94e9c04..d9d1c78 100644 --- a/website/eleventy.config.js +++ b/website/eleventy.config.js @@ -36,19 +36,20 @@ export default function(eleventyConfig) { {% endif %} </article>`; - const blogIndexOverride = `--- +const blogIndexOverride = `--- layout: layouts/base.njk -title: Blog +title: CommandTree Blog - VS Code Command Runner Guide Updates +description: CommandTree release notes and practical VS Code task runner guides for command discovery, AI summaries, mise tasks, monorepo workflows, and workspace automation. permalink: /blog/ --- <div class="blog-container"> <header class="blog-header"> <h1>Blog</h1> - <p class="blog-subtitle">Latest posts and updates</p> + <p class="blog-subtitle">Release notes and practical guides for VS Code task discovery.</p> </header> <nav class="blog-nav"> <a href="/blog/tags/" class="blog-nav-link">Tags</a> - <a href="/blog/categories/" class="blog-nav-link">Categories</a> + {% if collections.categoryList | length > 0 %}<a href="/blog/categories/" class="blog-nav-link">Categories</a>{% endif %} </nav> <div class="post-list"> {% for post in collections.posts | sortByDateDesc %} @@ -62,19 +63,20 @@ permalink: /blog/ </div> </div>`; - const tagsIndexOverride = `--- +const tagsIndexOverride = `--- layout: layouts/base.njk -title: Tags +title: CommandTree Blog Tags - VS Code Task Runner Topics +description: Browse CommandTree blog tags for VS Code command runner topics including AI summaries, task discovery, mise tasks, monorepos, and workspace automation. permalink: /blog/tags/ --- <div class="blog-container"> <header class="blog-header"> <h1>Tags</h1> - <p class="blog-subtitle">Browse blog posts by tag</p> + <p class="blog-subtitle">Browse CommandTree posts by VS Code task runner topic.</p> </header> <nav class="blog-nav"> <a href="/blog/" class="blog-nav-link">All posts</a> - <a href="/blog/categories/" class="blog-nav-link">Categories</a> + {% if collections.categoryList | length > 0 %}<a href="/blog/categories/" class="blog-nav-link">Categories</a>{% endif %} </nav> <ul class="taxonomy-grid"> {% for tag in collections.tagList %} @@ -87,15 +89,16 @@ permalink: /blog/tags/ </ul> </div>`; - const categoriesIndexOverride = `--- +const categoriesIndexOverride = `--- layout: layouts/base.njk -title: Categories +title: CommandTree Blog Categories - VS Code Task Runner Guides +description: Browse CommandTree blog categories for VS Code command runner guides covering task discovery, AI summaries, mise tasks, and workspace automation. permalink: /blog/categories/ --- <div class="blog-container"> <header class="blog-header"> <h1>Categories</h1> - <p class="blog-subtitle">Browse blog posts by category</p> + <p class="blog-subtitle">Browse CommandTree posts by guide category.</p> </header> <nav class="blog-nav"> <a href="/blog/" class="blog-nav-link">All posts</a> @@ -123,8 +126,8 @@ pagination: permalink: /blog/tags/{{ tag | slugify }}/ layout: layouts/base.njk eleventyComputed: - title: "Posts tagged '{{ tag | capitalize }}'" - description: "All blog posts tagged with {{ tag | capitalize }}." + title: "{{ tag | capitalize }} Articles - CommandTree VS Code Task Runner Blog" + description: "CommandTree articles tagged with {{ tag | capitalize }} for VS Code developers who need task discovery, command running, AI summaries, and workspace automation tips." --- <div class="blog-container"> <header class="blog-header"> @@ -151,8 +154,8 @@ pagination: permalink: /blog/categories/{{ category | slugify }}/ layout: layouts/base.njk eleventyComputed: - title: "{{ category | capitalize }}" - description: "All blog posts in the {{ category }} category." + title: "{{ category | capitalize }} Guides - CommandTree VS Code Task Runner Blog" + description: "CommandTree posts in the {{ category }} category for VS Code developers covering command runners, task discovery, AI summaries, and workspace automation." --- <div class="blog-container"> <header class="blog-header"> @@ -171,12 +174,46 @@ eleventyComputed: </div> </div>`; + const sitemapOverride = `---json +{ + "permalink": "sitemap.xml", + "eleventyExcludeFromCollections": true +} +--- +<?xml version="1.0" encoding="utf-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + {%- for page in collections.all %} + {%- set isTagPage = page.url.startsWith('/blog/tags/') %} + {%- set isCategoryPage = page.url.startsWith('/blog/categories/') %} + {%- if not page.data.eleventyExcludeFromCollections and not isTagPage and not isCategoryPage %} + <url> + <loc>{{ site.url }}{{ page.url }}</loc> + <lastmod>{{ page.date | isoDate }}</lastmod> + {%- if page.url == "/" or page.url == "/index.html" %} + <priority>1.0</priority> + <changefreq>weekly</changefreq> + {%- elif "/docs/" in page.url %} + <priority>0.8</priority> + <changefreq>monthly</changefreq> + {%- elif "/blog/" in page.url %} + <priority>0.7</priority> + <changefreq>monthly</changefreq> + {%- else %} + <priority>0.5</priority> + <changefreq>monthly</changefreq> + {%- endif %} + </url> + {%- endif %} + {%- endfor %} +</urlset>`; + const blogOverrides = { "blog/index.njk": blogIndexOverride, "blog/tags.njk": tagsIndexOverride, "blog/categories.njk": categoriesIndexOverride, "blog/tags-pages.njk": tagsPagesOverride, "blog/categories-pages.njk": categoriesPagesOverride, + "sitemap.njk": sitemapOverride, }; // Register as an inline plugin so it runs AFTER the techdoc plugin // (plugins are processed in addPlugin order, after the user config callback). @@ -296,6 +333,50 @@ eleventyComputed: return false; }; + const isTaxonomyUrl = (url) => { + if (!url) { return false; } + return url.startsWith("/blog/tags/") || url.startsWith("/blog/categories/"); + }; + + const findJsonLdBlock = (content) => { + const open = '<script type="application/ld+json">'; + const close = "</script>"; + const openStart = content.indexOf(open); + if (openStart < 0) { return null; } + const jsonStart = openStart + open.length; + const closeStart = content.indexOf(close, jsonStart); + if (closeStart < 0) { return null; } + return { jsonStart, closeStart }; + }; + + const asCollectionPage = (item) => { + if (item["@type"] !== "BlogPosting") { return item; } + const collectionPage = { ...item, "@type": "CollectionPage" }; + delete collectionPage.author; + delete collectionPage.datePublished; + return collectionPage; + }; + + const renderJsonLd = (data) => JSON.stringify(data, null, 2).split("\n").join("\n "); + + const rewriteTaxonomyJsonLd = (content) => { + const block = findJsonLdBlock(content); + if (!block) { return content; } + try { + const data = JSON.parse(content.slice(block.jsonStart, block.closeStart).trim()); + if (Array.isArray(data["@graph"])) { + data["@graph"] = data["@graph"].map(asCollectionPage); + } + return content.slice(0, block.jsonStart) + "\n " + renderJsonLd(data) + "\n " + content.slice(block.closeStart); + } catch { + return content; + } + }; + + const updateTaxonomySeo = (content) => rewriteTaxonomyJsonLd(content + .replace('<meta name="robots" content="index, follow">', '<meta name="robots" content="noindex, follow">') + .replace('<meta property="og:type" content="article">', '<meta property="og:type" content="website">')); + eleventyConfig.addTransform("blogHero", function(content) { if (!this.page.outputPath?.endsWith(".html")) { return content; @@ -315,6 +396,13 @@ eleventyComputed: ); }); + eleventyConfig.addTransform("taxonomySeo", function(content) { + if (!this.page.outputPath?.endsWith(".html") || !isTaxonomyUrl(this.page.url)) { + return content; + } + return updateTaxonomySeo(content); + }); + eleventyConfig.addTransform("llmsTxt", function(content) { if (!this.page.outputPath?.endsWith("llms.txt")) { return content; diff --git a/website/package-lock.json b/website/package-lock.json index 75f5469..9cb9a73 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -38,44 +38,44 @@ } }, "node_modules/@11ty/eleventy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.2.tgz", - "integrity": "sha512-IcsDlbXnBf8cHzbM1YBv3JcTyLB35EK88QexmVyFdVJVgUU6bh9g687rpxryJirHzo06PuwnYaEEdVZQfIgRGg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.5.tgz", + "integrity": "sha512-hZ0g6MwZyRxCqXsPm82gIM304LraKbUz3ZmezOSjsqxttZG6cHTib3Qq8QkESJoKwnr+yX1eyfOkPC5/mEgZnQ==", "license": "MIT", "dependencies": { - "@11ty/dependency-tree": "^4.0.0", - "@11ty/dependency-tree-esm": "^2.0.0", + "@11ty/dependency-tree": "^4.0.2", + "@11ty/dependency-tree-esm": "^2.0.4", "@11ty/eleventy-dev-server": "^2.0.8", - "@11ty/eleventy-plugin-bundle": "^3.0.6", + "@11ty/eleventy-plugin-bundle": "^3.0.7", "@11ty/eleventy-utils": "^2.0.7", "@11ty/lodash-custom": "^4.17.21", - "@11ty/posthtml-urls": "^1.0.1", - "@11ty/recursive-copy": "^4.0.2", + "@11ty/posthtml-urls": "^1.0.2", + "@11ty/recursive-copy": "^4.0.4", "@sindresorhus/slugify": "^2.2.1", "bcp-47-normalize": "^2.3.0", "chokidar": "^3.6.0", - "debug": "^4.4.1", + "debug": "^4.4.3", "dependency-graph": "^1.0.0", "entities": "^6.0.1", "filesize": "^10.1.6", "gray-matter": "^4.0.3", "iso-639-1": "^3.1.5", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "kleur": "^4.1.5", - "liquidjs": "^10.21.1", - "luxon": "^3.6.1", - "markdown-it": "^14.1.0", + "liquidjs": "^10.25.0", + "luxon": "^3.7.2", + "markdown-it": "^14.1.1", "minimist": "^1.2.8", - "moo": "^0.5.2", + "moo": "0.5.2", "node-retrieve-globals": "^6.0.1", "nunjucks": "^3.2.4", - "picomatch": "^4.0.2", + "picomatch": "^4.0.3", "please-upgrade-node": "^3.2.0", - "posthtml": "^0.16.6", + "posthtml": "^0.16.7", "posthtml-match-helper": "^2.0.3", - "semver": "^7.7.2", - "slugify": "^1.6.6", - "tinyglobby": "^0.2.14" + "semver": "^7.7.4", + "slugify": "^1.6.8", + "tinyglobby": "^0.2.15" }, "bin": { "eleventy": "cmd.cjs" @@ -214,9 +214,9 @@ } }, "node_modules/@11ty/posthtml-urls": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@11ty/posthtml-urls/-/posthtml-urls-1.0.2.tgz", - "integrity": "sha512-0vaV3Wt0surZ+oS1VdKKe0axeeupuM+l7W/Z866WFQwF+dGg2Tc/nmhk/5l74/Y55P8KyImnLN9CdygNw2huHg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@11ty/posthtml-urls/-/posthtml-urls-1.0.3.tgz", + "integrity": "sha512-1YvhnkaNlFnnJic1rBMWmTC2adbuy+JQiBfl1Hecr1Wjjik1pQZmGyk/eC9zKX/FQv52s2Nht1Gi/UwhYqrBeg==", "license": "MIT", "dependencies": { "evaluate-value": "^2.0.0", @@ -229,14 +229,14 @@ } }, "node_modules/@11ty/recursive-copy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-4.0.3.tgz", - "integrity": "sha512-SX48BTLEGX8T/OsKWORsHAAeiDsbFl79Oa/0Wg/mv/d27b7trCVZs7fMHvpSgDvZz/fZqx5rDk8+nx5oyT7xBw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-4.0.4.tgz", + "integrity": "sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==", "license": "ISC", "dependencies": { "errno": "^1.0.0", "junk": "^3.1.0", - "maximatch": "^0.1.0", + "minimatch": "^3.1.5", "slash": "^3.0.0" }, "engines": { @@ -578,13 +578,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.2" + "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -656,9 +656,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -668,9 +668,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -717,9 +717,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -734,45 +734,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/array-differ": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", - "integrity": "sha512-LeZY+DZDRnvP7eMuQ6LHfCzUGxAAIViUBliK24P3hWXL6y4SortgR6Nim6xrkfSLlmH0+k+9NYNwVC2s53ZrYQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-union": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", - "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", - "license": "MIT", - "dependencies": { - "array-uniq": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-uniq": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", - "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -837,9 +798,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1550,9 +1511,9 @@ } }, "node_modules/liquidjs": { - "version": "10.24.0", - "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.24.0.tgz", - "integrity": "sha512-TAUNAdgwaAXjjcUFuYVJm9kOVH7zc0mTKxsG9t9Lu4qdWjB2BEblyVIYpjWcmJLMGgiYqnGNJjpNMHx0gp/46A==", + "version": "10.25.6", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.6.tgz", + "integrity": "sha512-h5ki5HS1PiL9/NmLw3iUcTF1jQswKJd8KLEXNrtSf8XHF0v3c5+d+8llz3N9I5IUdc5rsOuVLb9AVnqvqqscPg==", "license": "MIT", "dependencies": { "commander": "^10.0.0" @@ -1585,9 +1546,9 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1", @@ -1623,21 +1584,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/maximatch": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/maximatch/-/maximatch-0.1.0.tgz", - "integrity": "sha512-9ORVtDUFk4u/NFfo0vG/ND/z7UQCVZBL539YW0+U1I7H1BkZwizcPx5foFv7LCPcBnm2U6RjFnQOsIvN4/Vm2A==", - "license": "MIT", - "dependencies": { - "array-differ": "^1.0.0", - "array-union": "^1.0.1", - "arrify": "^1.0.0", - "minimatch": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -1682,9 +1628,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -1703,10 +1649,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -1820,9 +1766,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -1832,13 +1778,13 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -1851,9 +1797,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1982,9 +1928,9 @@ } }, "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -2084,9 +2030,9 @@ } }, "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", "license": "MIT", "engines": { "node": ">=8.0.0" @@ -2155,13 +2101,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2227,9 +2173,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/website/package.json b/website/package.json index bf4158c..97c4d40 100644 --- a/website/package.json +++ b/website/package.json @@ -4,8 +4,9 @@ "main": "index.js", "scripts": { "test": "npx playwright test", - "dev": "npx @11ty/eleventy --serve", - "build": "npx @11ty/eleventy" + "clean": "node -e \"require('node:fs').rmSync('_site',{recursive:true,force:true})\"", + "dev": "npm run clean && npx @11ty/eleventy --serve", + "build": "npm run clean && npx @11ty/eleventy" }, "keywords": [], "author": "", diff --git a/website/playwright.config.js b/website/playwright.config.js index 9df5d79..14a28e8 100644 --- a/website/playwright.config.js +++ b/website/playwright.config.js @@ -11,7 +11,7 @@ export default defineConfig({ trace: "on-first-retry", }, webServer: { - command: "npx @11ty/eleventy --serve --port=8080", + command: "npm run dev -- --port=8080", port: 8080, reuseExistingServer: !process.env.CI, timeout: 30000, diff --git a/website/playwright.config.ts b/website/playwright.config.ts index 5a2b313..cefd575 100644 --- a/website/playwright.config.ts +++ b/website/playwright.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ }, ], webServer: { - command: 'npx @11ty/eleventy --serve --port=8080', + command: 'npm run dev -- --port=8080', url: 'http://localhost:8080', reuseExistingServer: !process.env['CI'], timeout: 30000, diff --git a/website/src/assets/css/styles.css b/website/src/assets/css/styles.css index 81e8cfd..7014ce8 100644 --- a/website/src/assets/css/styles.css +++ b/website/src/assets/css/styles.css @@ -1051,7 +1051,7 @@ li::marker { color: var(--color-primary); } h1, h2, h3, h4, h5, h6 { max-width: 100% !important; overflow-wrap: break-word !important; - word-break: break-word !important; + word-break: normal !important; hyphens: auto !important; } @@ -1077,11 +1077,15 @@ li::marker { color: var(--color-primary); } .mobile-menu-toggle { display: flex; flex-direction: column; + align-items: center; + justify-content: center; gap: 4px; background: none; border: none; cursor: pointer; - padding: 0.5rem; + width: 48px; + height: 48px; + padding: 0; color: var(--color-text); } @@ -1179,8 +1183,8 @@ li::marker { color: var(--color-primary); } .docs-content ul, .docs-content ol, .docs-content li { - word-wrap: break-word !important; overflow-wrap: break-word !important; + word-break: normal !important; max-width: 100% !important; } @@ -1189,7 +1193,8 @@ li::marker { color: var(--color-primary); } max-width: 100% !important; overflow-x: auto !important; white-space: pre-wrap !important; - word-break: break-all !important; + overflow-wrap: anywhere !important; + word-break: normal !important; } /* Fix any containers with set widths */ @@ -1212,8 +1217,8 @@ li::marker { color: var(--color-primary); } /* CRITICAL: Force text wrapping for all text elements */ p, span, a, li, td, th, label, button { - word-break: break-word !important; overflow-wrap: break-word !important; + word-break: normal !important; hyphens: auto !important; max-width: 100% !important; } diff --git a/website/src/blog/ai-summaries-hover.md b/website/src/blog/ai-summaries-hover.md index aa705db..ac1f5cf 100644 --- a/website/src/blog/ai-summaries-hover.md +++ b/website/src/blog/ai-summaries-hover.md @@ -36,7 +36,7 @@ Copilot also flags dangerous operations. If a script runs `rm -rf`, force-pushes ## Stored Locally, Updated Automatically -Summaries are cached in a local SQLite database at `.commandtree/commandtree.sqlite3` in your workspace. They persist across sessions and only regenerate when the underlying script content changes, so there is no repeated API overhead. +Summaries are cached in a local SQLite database at `.commandtree/commandtree.sqlite3` in your workspace. They persist across sessions and only regenerate when the underlying script content changes, reducing repeated summary generation. ## Works Without Copilot diff --git a/website/src/blog/mise-tasks-vscode.md b/website/src/blog/mise-tasks-vscode.md index 85dce4f..1b18e22 100644 --- a/website/src/blog/mise-tasks-vscode.md +++ b/website/src/blog/mise-tasks-vscode.md @@ -1,7 +1,7 @@ --- layout: layouts/blog.njk title: Run Mise Tasks From the VS Code Sidebar - CommandTree 0.9.0 -description: CommandTree 0.9.0 auto-discovers every mise task in your workspace - mise.toml, .mise.toml, mise.yaml - and runs them from the VS Code sidebar alongside npm, Make, Just, and 18 other command types. +description: CommandTree 0.9.0 discovers mise tasks from mise.toml, .mise.toml, and mise.yaml, then runs them from the VS Code sidebar beside npm, Make, and Just. date: 2026-04-06 author: Christian Findlay tags: @@ -36,17 +36,17 @@ Both TOML tasks (`[tasks.build]` sections) and YAML task maps work. Descriptions ## One Click to Run -Click any mise task and CommandTree opens a new terminal in the same directory as the `mise.toml` file and runs `mise run <task>`. Tool versions, environment variables, and dependencies all resolve normally — *it is exactly the same command you would type yourself*. Tasks with parameters get prompted for input before they run. +Click any mise task and CommandTree opens a new terminal in the same directory as the `mise.toml` file and runs `mise run <task>`, matching the command format in the [mise task runner documentation](https://mise.jdx.dev/tasks/). Tasks with parameters get prompted for input before they run. ## Mise *And* Everything Else -This is the part the mise-only extensions can't do. Most real projects are not pure mise. There is a `Makefile` from before the migration, an `npm run lint` script in `package.json`, a couple of shell scripts in `scripts/`, maybe a `Justfile` for the deploy step. +Projects often keep more than one task system around: a `Makefile` from before the migration, an `npm run lint` script in `package.json`, shell scripts in `scripts/`, or a `Justfile` for the deploy step. CommandTree discovers **22 command types** and shows them in one tree: mise tasks, npm scripts, Makefile targets, Just recipes, Taskfile, shell scripts, Python scripts, PowerShell, Cargo, Gradle, Maven, Ant, Deno, Rake, Composer, Docker Compose services, .NET projects, C# scripts, F# scripts, VS Code tasks, launch configs, and Markdown files. -**One extension instead of three.** Filter by tag, pin favourites, search by text — it all works across every command type at once. +Filter by tag, pin favourites, and search by text across every command type at once. ## Hover to See What a Task Does diff --git a/website/src/docs/ai-summaries.md b/website/src/docs/ai-summaries.md index 31841ce..50683b5 100644 --- a/website/src/docs/ai-summaries.md +++ b/website/src/docs/ai-summaries.md @@ -41,7 +41,7 @@ Each summary is a one-to-two sentence plain-language description of what the com ### Are summaries stored locally? -Yes. All summaries are stored in a SQLite database at `.commandtree/commandtree.sqlite3` in your workspace root. No data is sent to external servers beyond the GitHub Copilot API that runs locally in VS Code. +Yes. Summaries are stored in a SQLite database at `.commandtree/commandtree.sqlite3` in your workspace root. When AI summaries are enabled, CommandTree uses the installed [GitHub Copilot](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) extension to generate the text. ### How are security warnings triggered? diff --git a/website/src/index.njk b/website/src/index.njk index de544c2..2583497 100644 --- a/website/src/index.njk +++ b/website/src/index.njk @@ -36,7 +36,7 @@ date: git Last Modified <span class="demo-dot demo-dot-max"></span> <span class="demo-titlebar-text">CommandTree — VS Code</span> </div> - <img src="/assets/images/CommandTree.gif" alt="CommandTree discovering and running commands in VS Code" class="demo-gif" loading="lazy"> + <img src="/assets/images/CommandTree.gif" alt="CommandTree discovering and running commands in VS Code" class="demo-gif" loading="eager"> </div> <p class="demo-caption">Discover, filter, and run every command in your workspace from one panel.</p> </div> diff --git a/website/tests/seo.spec.ts b/website/tests/seo.spec.ts index a8157d1..a245698 100644 --- a/website/tests/seo.spec.ts +++ b/website/tests/seo.spec.ts @@ -8,8 +8,33 @@ const ALL_PAGES = [ '/docs/execution/', '/docs/configuration/', '/blog/', + '/blog/introducing-commandtree/', + '/blog/ai-summaries-hover/', + '/blog/mise-tasks-vscode/', ]; +const TAXONOMY_PAGES = [ + '/blog/tags/', + '/blog/tags/mise/', + '/blog/categories/', +]; + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null; +} + +function getGraph(value: unknown): Record<string, unknown>[] { + if (!isRecord(value)) { + return []; + } + const graph = value['@graph']; + return Array.isArray(graph) ? graph.filter(isRecord) : []; +} + +function getString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + test.describe('SEO and Meta', () => { test('homepage has meta description', async ({ page }) => { await page.goto('/'); @@ -36,8 +61,12 @@ test.describe('SEO and Meta', () => { await page.goto(url); const content = await page.locator('meta[name="description"]').getAttribute('content'); expect(content, `${url} should have a meta description`).toBeTruthy(); - expect(content!.length, `${url} description should be at least 50 chars`).toBeGreaterThanOrEqual(50); - descriptions.push(content!); + if (!content) { + continue; + } + expect(content.length, `${url} description should be at least 120 chars`).toBeGreaterThanOrEqual(120); + expect(content.length, `${url} description should be at most 170 chars`).toBeLessThanOrEqual(170); + descriptions.push(content); } const unique = new Set(descriptions); expect(unique.size, 'All pages should have unique meta descriptions').toBe(descriptions.length); @@ -49,6 +78,8 @@ test.describe('SEO and Meta', () => { await page.goto(url); const title = await page.title(); expect(title, `${url} should have a title`).toBeTruthy(); + expect(title.length, `${url} title should be at least 30 chars`).toBeGreaterThanOrEqual(30); + expect(title.length, `${url} title should be at most 70 chars`).toBeLessThanOrEqual(70); titles.push(title); } const unique = new Set(titles); @@ -84,11 +115,32 @@ test.describe('SEO and Meta', () => { expect(count, `${url} should have JSON-LD`).toBeGreaterThanOrEqual(1); for (let i = 0; i < count; i++) { const text = await scripts.nth(i).textContent(); - expect(() => JSON.parse(text!), `${url} JSON-LD should be valid JSON`).not.toThrow(); + expect(text, `${url} JSON-LD should not be empty`).toBeTruthy(); + if (!text) { + continue; + } + expect(() => JSON.parse(text), `${url} JSON-LD should be valid JSON`).not.toThrow(); } } }); + test('taxonomy pages are noindex collection pages', async ({ page }) => { + for (const url of TAXONOMY_PAGES) { + await page.goto(url); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex, follow'); + await expect(page.locator('meta[property="og:type"]')).toHaveAttribute('content', 'website'); + const text = await page.locator('script[type="application/ld+json"]').first().textContent(); + expect(text, `${url} should have JSON-LD`).toBeTruthy(); + if (!text) { + continue; + } + const graph = getGraph(JSON.parse(text)); + const pageNode = graph.find((item) => getString(item['url']).endsWith(url)); + expect(getString(pageNode?.['@type']), `${url} should use CollectionPage schema`).toBe('CollectionPage'); + expect(pageNode?.['datePublished'], `${url} should not use article dates`).toBeUndefined(); + } + }); + test('homepage has og:image', async ({ page }) => { await page.goto('/'); const ogImage = await page.locator('meta[property="og:image"]').getAttribute('content'); @@ -111,7 +163,10 @@ test.describe('SEO and Meta', () => { for (let i = 0; i < count; i++) { const alt = await images.nth(i).getAttribute('alt'); expect(alt, `Image ${i} should have alt text`).toBeTruthy(); - expect(alt!.length, `Image ${i} alt text should be descriptive`).toBeGreaterThan(3); + if (!alt) { + continue; + } + expect(alt.length, `Image ${i} alt text should be descriptive`).toBeGreaterThan(3); } });