diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1e92608d2..be7663305 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -78,6 +78,15 @@ "metadata": { "recommended": false } + }, + { + "name": "aidd-qa", + "source": "./plugins/aidd-qa", + "description": "Acceptance QA: validates observable behavior against acceptance criteria and records reviewer evidence. Browser is the only supported interface today.", + "strict": true, + "metadata": { + "recommended": false + } } ] } diff --git a/.claude/hooks/check-written-file.js b/.claude/hooks/check-written-file.js new file mode 100644 index 000000000..0d66fd46c --- /dev/null +++ b/.claude/hooks/check-written-file.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// PostToolUse hook — checks a file an agent just wrote under `cli/` with the same Biome check +// pre-commit runs, so a broken rule comes back in the same turn instead of at the next gate. +// +// Docs: https://code.claude.com/docs/en/hooks#posttooluse +// stdin : JSON { tool_input: { file_path } } +// exit 2: the Biome report on stderr, which Claude Code hands back to the agent +// exit 0: silence — a clean file, one outside `cli/`, or no Biome installed to ask + +const { spawnSync } = require("node:child_process"); +const { existsSync, lstatSync, readFileSync } = require("node:fs"); +const path = require("node:path"); + +const CLI = path.resolve(__dirname, "..", "..", "cli"); +const WINDOWS = process.platform === "win32"; +const BIOME = path.join(CLI, "node_modules", ".bin", WINDOWS ? "biome.cmd" : "biome"); +const CHECKED = /\.(ts|mts|cts|js|mjs|cjs|json|jsonc)$/; + +function writtenFile() { + try { + return JSON.parse(readFileSync(0, "utf8")).tool_input?.file_path ?? ""; + } catch { + return ""; + } +} + +function main() { + const file = writtenFile(); + if (file === "" || !CHECKED.test(file) || !existsSync(file) || lstatSync(file).isSymbolicLink()) { + return 0; + } + const relative = path.relative(CLI, path.resolve(file)); + if (relative.startsWith("..") || path.isAbsolute(relative)) return 0; + if (relative.split(path.sep).includes("node_modules") || !existsSync(BIOME)) return 0; + const result = spawnSync(BIOME, ["check", "--write", "--no-errors-on-unmatched", "--diagnostic-level=error", relative], { + cwd: CLI, + encoding: "utf8", + shell: WINDOWS, + }); + if (result.status === 0) return 0; + process.stderr.write(`${result.stdout}${result.stderr}`); + return 2; +} + +process.exitCode = main(); diff --git a/.claude/settings.json b/.claude/settings.json index f5ebe8d8d..1850d4990 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,6 +10,18 @@ } ] } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-written-file.js\"", + "timeout": 60 + } + ] + } ] }, "enabledPlugins": { diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json index 72de2e226..be73312c0 100644 --- a/.github/rulesets/main.json +++ b/.github/rulesets/main.json @@ -16,6 +16,7 @@ { "type": "pull_request", "parameters": { + "allowed_merge_methods": ["merge"], "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": false, "require_code_owner_review": true, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25b6d331f..0ee0bfacd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,9 @@ jobs: always() && github.event_name == 'push' && (needs.commitlint.result == 'success' || needs.commitlint.result == 'skipped') + # contents: read for the credit step's checkout. + permissions: + contents: read outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} @@ -75,11 +78,12 @@ jobs: # bypass actor. The App token also re-fires the `push: main` and `release: published` # workflows a GITHUB_TOKEN merge would not. Guarded on prs_created, since # releases_created only fires on merge. + # `--merge`: main allows only merge commits. `--body ""`: a PR-title body is parsed as a 2nd commit. - name: Auto-merge the Release PR if: ${{ steps.release.outputs.prs_created == 'true' }} env: GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: gh pr merge "${{ fromJSON(steps.release.outputs.pr).number }}" --squash --admin --repo "${{ github.repository }}" + run: gh pr merge "${{ fromJSON(steps.release.outputs.pr).number }}" --merge --admin --body "" --repo "${{ github.repository }}" # GitHub marks whichever release is created last as "Latest", and release-please # creates the umbrella and every plugin release in one unordered run — so a plugin @@ -91,6 +95,22 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: gh release edit "${{ steps.release.outputs.tag_name }}" --latest --repo "${{ github.repository }}" + # Credits commit authors; native `include-commit-authors` is broken (release-please#2761). + # Remove with the script once #2892 ships. continue-on-error: never blocks the publish jobs. + - name: Checkout + if: ${{ steps.release.outputs.releases_created == 'true' }} + continue-on-error: true + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Credit contributors on the created release(s) + if: ${{ steps.release.outputs.releases_created == 'true' }} + continue-on-error: true + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_OUTPUTS: ${{ toJSON(steps.release.outputs) }} + run: node scripts/credit-release-authors.cjs "${{ github.repository }}" + build-and-attach: name: Build and attach marketplace needs: [release-please] @@ -134,7 +154,7 @@ jobs: contents: write strategy: fail-fast: false - # Four marketplace plus five flat — opencode is flat-only. Mirrors the CLI golden + # Four marketplace plus six flat — opencode and kilo are flat-only. Mirrors the CLI golden # snapshot matrix. matrix: include: @@ -147,6 +167,7 @@ jobs: - { tool: copilot, mode: flat } - { tool: codex, mode: flat } - { tool: opencode, mode: flat } + - { tool: kilo, mode: flat } steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -211,6 +232,7 @@ jobs: aidd-refine, aidd-ui, aidd-telemetry, + aidd-qa, ] steps: - name: Check if this plugin was released diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 9d223e5ac..fa042e3de 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -21,14 +21,20 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: + actions: read contents: read jobs: changes: name: cli / changes + permissions: + actions: read + contents: read + pull-requests: read runs-on: ubuntu-latest outputs: relevant: ${{ steps.filter.outputs.relevant }} + trusted_promotion: ${{ steps.promotion.outputs.trusted }} mutation_scopes: ${{ steps.mutation.outputs.scopes }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -81,25 +87,120 @@ jobs: echo "relevant=$relevant" >> "$GITHUB_OUTPUT" + # A promotion branch is a snapshot of next, not the live next branch. Reuse only proves + # a tree already gated on next: every missing Git or API proof falls back to mutations. + - name: Check whether a promotion snapshot or main merge passed next + id: promotion + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + GITHUB_REF: ${{ github.ref }} + CURRENT_SHA: ${{ github.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + run: | + set -euo pipefail + trusted=false + reason="not a reusable promotion snapshot" + + has_successful_next_gate() { + local run_ids run_id gate + reason="no successful cli CI push run found for the snapshot SHA" + if run_ids="$(gh api --paginate "/repos/$REPO/actions/workflows/cli-ci.yml/runs?branch=next&event=push&status=completed&head_sha=$HEAD_SHA&per_page=100" \ + --jq '.workflow_runs[] | select(.conclusion == "success") | .id' 2>/dev/null)"; then + while IFS= read -r run_id; do + [[ -z "$run_id" ]] && continue + if gate="$(gh api "/repos/$REPO/actions/runs/$run_id/jobs?filter=latest&per_page=100" \ + --jq 'any(.jobs[]; .name == "cli / gate" and .conclusion == "success")' 2>/dev/null)"; then + if [[ "$gate" == "true" ]]; then + reason="reusing the successful cli CI gate from next run $run_id" + return 0 + fi + reason="cli / gate did not pass in next run $run_id" + else + reason="could not inspect cli CI run $run_id" + fi + done <<< "$run_ids" + else + reason="could not list successful cli CI push runs" + fi + return 1 + } + + # GitHub tests pull requests at a synthetic merge ref. The source gate may be reused + # only if main is already in the snapshot and that merge ref has the same file tree. + if [[ "$EVENT_NAME" == "pull_request" && "$BASE_REF" == "main" && "$HEAD_REF" =~ ^promote/next-to-main-[0-9]+$ && "$HEAD_REPO" == "$REPO" ]]; then + if ! git merge-base --is-ancestor "$BASE_SHA" "$HEAD_SHA" 2>/dev/null; then + reason="main base is not an ancestor of the promotion snapshot" + elif ! snapshot_tree="$(git rev-parse "$HEAD_SHA^{tree}" 2>/dev/null)" || ! merge_tree="$(git rev-parse "$CURRENT_SHA^{tree}" 2>/dev/null)"; then + reason="could not prove the promotion merge tree" + elif [[ "$merge_tree" != "$snapshot_tree" ]]; then + reason="promotion merge tree differs from the snapshot" + elif has_successful_next_gate; then + trusted=true + fi + # A main push is reusable only for the exact merge of a numbered, same-repository + # promotion PR. Parent two is its source snapshot; neither a squash nor an unrelated + # merge can satisfy this proof. + elif [[ "$EVENT_NAME" == "push" && "$GITHUB_REF" == "refs/heads/main" ]]; then + if ! parents="$(git rev-list --parents -n 1 "$CURRENT_SHA" 2>/dev/null)" || ! read -r merge_sha first_parent snapshot_sha extra_parent <<< "$parents" || [[ "$merge_sha" != "$CURRENT_SHA" || -z "$first_parent" || -z "$snapshot_sha" || -n "${extra_parent:-}" ]]; then + reason="main commit is not a two-parent merge" + elif ! snapshot_tree="$(git rev-parse "$snapshot_sha^{tree}" 2>/dev/null)" || ! main_tree="$(git rev-parse "$CURRENT_SHA^{tree}" 2>/dev/null)"; then + reason="could not read the main merge or snapshot tree" + elif [[ "$main_tree" != "$snapshot_tree" ]]; then + reason="main merge tree differs from the promotion snapshot" + elif ! prs="$(gh api "/repos/$REPO/commits/$CURRENT_SHA/pulls" \ + --jq '.[] | select(.base.ref == "main" and (.head.ref | test("^promote/next-to-main-[0-9]+$")) and .merged_at != null) | [.base.repo.full_name, .head.repo.full_name, .head.ref, .head.sha, .merged_at, .merge_commit_sha] | @tsv' 2>/dev/null)"; then + reason="could not inspect pull requests associated with the main commit" + else + matching_prs=0 + while IFS=$'\t' read -r base_repo head_repo head_ref pr_head_sha merged_at merge_commit_sha; do + if [[ "$base_repo" == "$REPO" && "$head_repo" == "$REPO" && "$head_ref" =~ ^promote/next-to-main-[0-9]+$ && "$pr_head_sha" == "$snapshot_sha" && -n "$merged_at" && "$merge_commit_sha" == "$CURRENT_SHA" ]]; then + ((matching_prs += 1)) + fi + done <<< "$prs" + + if [[ "$matching_prs" -ne 1 ]]; then + reason="no unique matching promotion pull request proved this main merge" + else + HEAD_SHA="$snapshot_sha" + if has_successful_next_gate; then + trusted=true + fi + fi + fi + fi + + echo "promotion mutation reuse: $reason" + echo "trusted=$trusted" >> "$GITHUB_OUTPUT" + - name: Decide which mutation scopes a change can move id: mutation run: | set -euo pipefail - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - BASE="${{ github.event.pull_request.base.sha }}" - HEAD="${{ github.event.pull_request.head.sha }}" - else - BASE="${{ github.event.before }}" - HEAD="${{ github.sha }}" - fi - if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]]; then - changed="" - all=true + if [[ "${{ steps.promotion.outputs.trusted }}" == "true" ]]; then + scopes='[]' else - changed="$(git diff --name-only "$BASE" "$HEAD")" - all=false + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + else + BASE="${{ github.event.before }}" + HEAD="${{ github.sha }}" + fi + if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]]; then + changed="" + all=true + else + changed="$(git diff --name-only "$BASE" "$HEAD")" + all=false + fi + scopes="$(ALL="$all" CHANGED="$changed" node cli/scripts/mutation-scopes-to-run.mjs)" fi - scopes="$(ALL="$all" CHANGED="$changed" node cli/scripts/mutation-scopes-to-run.mjs)" echo "mutation scopes: $scopes" echo "scopes=$scopes" >> "$GITHUB_OUTPUT" @@ -218,6 +319,32 @@ jobs: # token and no network. `smoke:full` adds the remote-fetch section on demand. - run: cd cli && pnpm smoke + cli-kilo-runtime: + name: cli / Kilo runtime smoke + needs: [changes] + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install pnpm + run: corepack enable + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - run: cd cli && pnpm install --frozen-lockfile + # The test starts Kilo's local server and asks it to load the generated project plugin. + # No model or account is needed for this protocol-level smoke. + - name: Install Kilo Code CLI + run: npm install -g @kilocode/cli@7.7.5 + - run: cd cli && pnpm test:e2e:kilo + cli-build: name: cli / Build & Bundle Budget needs: [changes] @@ -490,6 +617,7 @@ jobs: - cli-architecture - cli-coverage - cli-smoke + - cli-kilo-runtime - cli-build - cli-knip - identifier-join @@ -510,6 +638,7 @@ jobs: "${{ needs.cli-architecture.result }}" \ "${{ needs.cli-coverage.result }}" \ "${{ needs.cli-smoke.result }}" \ + "${{ needs.cli-kilo-runtime.result }}" \ "${{ needs.cli-build.result }}" \ "${{ needs.cli-knip.result }}" \ "${{ needs.identifier-join.result }}" \ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 600a5211d..df3419176 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -30,15 +30,15 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: languages: ${{ matrix.language }} queries: security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/autobuild@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 347c6371c..5e8824a92 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -67,8 +67,9 @@ jobs: # `ci:` on purpose: the type is absent from release-please's changelog-sections, # so this plumbing commit never surfaces in a changelog, and commitlint accepts it. + # `--body ""`: keeps the PR title out of the merge body, which release-please would re-parse. gh pr merge "$PR" --repo "$REPO" --merge --auto --delete-branch \ - --subject "ci: promote next to main (#${PR})" + --subject "ci: promote next to main (#${PR})" --body "" # Without a recorded subject GitHub generates one that is not conventional, and # commitlint lints main's tip on push. diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c54203832..5727ea153 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -8,5 +8,6 @@ "plugins/aidd-refine": "3.0.1", "plugins/aidd-ui": "0.2.1-alpha.0", "plugins/aidd-telemetry": "0.2.0", + "plugins/aidd-qa": "0.1.0", "cli": "5.3.0" } diff --git a/README.md b/README.md index 7507f3b57..760238e8d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Unify **engineering teams** around **standardized workflows** and **shared best 🧱 **IDE agnostic** · 🏗️ **Legacy systems** · 🌱 **Token-optimized** · 🇫🇷 **Made in France**

- 8 plugins · 50 skills · 2 agents + 9 plugins · 51 skills · 2 agents

[![Open Source](https://img.shields.io/badge/Open_Source-Yes-yellow?logo=open-source-initiative&logoColor=white)](https://opensource.org/) @@ -37,7 +37,7 @@ Why not just write your own commands? → [FAQ](docs/FAQ.md#-why-aidd-instead-of ## ✅ Prerequisites -- **An AI coding tool** — Claude Code (native), or Cursor / Copilot / Codex / OpenCode (see [Compatibility](#-compatibility)). +- **An AI coding tool** — Claude Code (native), or Cursor / Copilot / Codex / OpenCode / Kilo Code (see [Compatibility](#-compatibility)). - **[Node](https://nodejs.org) 22 or later** on your `PATH`, only for the plugin that ships hooks ([what they do](docs/ARCHITECTURE.md#-bundled-hooks)); the workflows themselves are markdown and need nothing. ## 🔌 Compatibility @@ -49,6 +49,7 @@ Why not just write your own commands? → [FAQ](docs/FAQ.md#-why-aidd-instead-of | **GitHub Copilot** | ✅ Supported | Marketplace · Flat | | **Codex** | ✅ Supported | Marketplace · Flat | | **OpenCode** | ✅ Supported | Flat | +| **Kilo Code** | ✅ Supported | Flat | | **Gemini · Mistral** | 🚧 In progress | — | **Marketplace** = installed and updated through your tool's plugin manager. **Flat** = files copied directly into your project, no plugin manager involved. Install steps per tool → [Other tools](#other-tools). @@ -57,7 +58,7 @@ Why not just write your own commands? → [FAQ](docs/FAQ.md#-why-aidd-instead-of ### Claude Code -Installs the 6 stable plugins (`aidd-ui` is 🚧 alpha and `aidd-telemetry` 🧪 beta, install separately — see [Plugins](#-plugins)). +Installs the 6 stable plugins (`aidd-ui` is 🚧 alpha, `aidd-telemetry` 🧪 beta, and `aidd-qa` 🆕 new, install those separately — see [Plugins](#-plugins)). **In the session** (slash commands) @@ -174,6 +175,16 @@ codex plugin add aidd-context@aidd-framework # per plugin +
+Kilo Code — Flat only + +1. Unzip the `kilo-flat` archive into your project root → `.kilo/`, including `.kilo/kilo.jsonc`. +2. Start a new Kilo session so it loads the generated project plugin. + +[Plugins documentation](https://kilo.ai/docs/automate/extending/plugins) + +
+ ## 🚀 Quick start Three ways in — pick one: @@ -229,7 +240,7 @@ learning only when it is durable enough to improve the next feature. ## 🧩 Plugins -Eight plugins covering the whole SDLC — **install all of them**; they work together. (`aidd-ui` is 🚧 **alpha** and `aidd-telemetry` 🧪 **beta** — both off the curated path.) +Nine plugins covering the whole SDLC — **install the six stable ones**; they work together. (`aidd-ui` is 🚧 **alpha**, `aidd-telemetry` 🧪 **beta**, and `aidd-qa` 🆕 **new** — all three off the curated path.) @@ -246,9 +257,9 @@ Project init, memory bank, context-artifact generation, diagrams, learning, expl ### ⚙️ [aidd-dev](plugins/aidd-dev/README.md) -`11 skills` · stable +`10 skills` · stable -Code transformation: plan, implement, assert, audit, review, test, refactor, debug. Standalone Browser QA records short web evidence. +Code transformation: plan, implement, assert, audit, review, test, refactor, debug. - +
@@ -275,9 +286,9 @@ Three Amigos refinement, Product Briefs, Epics, User Stories, Tasks, Spikes, Def ### 🪞 [aidd-refine](plugins/aidd-refine/README.md) -`4 skills` · stable +`5 skills` · stable -Brainstorm, challenge, shadow-areas, fact-check. +Brainstorm, challenge, shadow-areas, fact-check, improve. @@ -309,7 +320,15 @@ UI / UX design — smoke-test only, not ready for use. Answers what a piece of work cost — tokens, models, and which skill spent them. The switch is git-tracked, so it applies to everyone who clones; opt out per person with `AIDD_TELEMETRY=0`. Nothing leaves your machine. + +### 🎬 [aidd-qa](plugins/aidd-qa/README.md) 🆕 + +`1 skill` · **new** + +Acceptance QA — locks browser scenarios from acceptance criteria and records reviewer evidence. + +
diff --git a/RELEASE.md b/RELEASE.md index f3ab590a8..058eeefe8 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -41,6 +41,7 @@ flowchart LR ## 🚑 Hotfix -1. Branch `hotfix/*` from `main`, fix, PR back to `main`. -2. release-please cuts a dedicated patch release. -3. `main` is back-merged into `next` automatically. +1. Branch `hotfix/*` from `main`, fix, PR back to `main`. `.github/rulesets/main.json` declares merge as the only method the PR may use, so write every commit on the branch as conventional on its own — none gets squashed away. +2. Merge with an empty description — clear the box in the UI, or run `gh pr merge --merge --body ""` — because this repository's merge commits default their body to the PR title, and release-please reads a non-empty body as a second, duplicate commit in the next release's notes. +3. release-please cuts a dedicated patch release. +4. `main` is back-merged into `next` automatically. diff --git a/aidd_docs/memory/architecture.md b/aidd_docs/memory/architecture.md index 7390570bf..7ba45d316 100644 --- a/aidd_docs/memory/architecture.md +++ b/aidd_docs/memory/architecture.md @@ -10,13 +10,13 @@ The macro technical shape: the stack, how the pieces fit, and the decisions behi | --- | --- | | Product | markdown — skills, agents, rules, templates. No framework runtime; an LLM interprets them. | | Delivery | Node `>=22.12`, pnpm. `cli/` is the `aidd` binary; `kanban/` is a private package. | -| Manifest | `.claude-plugin/marketplace.json`, the plugin manifest: 8 plugins, no version among them. Versions are release-please's, in `deployment.md`. | +| Manifest | `.claude-plugin/marketplace.json`, the plugin manifest: 9 plugins, no version among them. Versions are release-please's, in `deployment.md`. | ## How it fits together ```mermaid flowchart LR - Manifest[".claude-plugin/marketplace.json"] -->|lists| Plugins["plugins/ · 8"] + Manifest[".claude-plugin/marketplace.json"] -->|lists| Plugins["plugins/ · 9"] Plugins -->|ships| Surfaces["skills · agents · commands · hooks · rules"] CLI["cli/ · aidd"] -->|reads| Manifest CLI -->|installs| Target["a project's AI tool dir"] @@ -40,6 +40,6 @@ The concern-to-plugin taxonomy is canonical in [`docs/ARCHITECTURE.md`](../../do ## Gotchas -- 8 plugins ship, 2 off the curated install path: `aidd-ui` is alpha, `aidd-telemetry` beta and opt-in. +- 9 plugins ship, 3 off the curated install path: `aidd-ui` is alpha, `aidd-telemetry` beta and opt-in, `aidd-qa` new and unproven outside this repository. - A skill never links outside itself: the tree ships both flat and as a marketplace, so no relative path survives both. - Bundled hooks run Node. No `node` on `PATH`, no memory refresh and no run journal. diff --git a/aidd_docs/memory/coding-assertions.md b/aidd_docs/memory/coding-assertions.md index 886a4fe62..f68a25294 100644 --- a/aidd_docs/memory/coding-assertions.md +++ b/aidd_docs/memory/coding-assertions.md @@ -26,7 +26,7 @@ Never state in a commit message or a report anything not just observed in output | Order | Command | Checks | | ----- | ------- | ------ | -| 1 | `pnpm exec lefthook run pre-commit` | JSON and YAML validity, `scripts/` tests, skill frontmatter and argument hints, context imports and reference form, markdown links, the paths the prose names and a sentence written in two documents (`scripts/check-doc-duplication.js`); `cli` lint, architecture, typecheck and type honesty when `cli/` changed. `cli` knip and the full `cli` suite are pre-push, not pre-commit — see below | +| 1 | `pnpm exec lefthook run pre-commit` | JSON and YAML validity, `scripts/` tests, skill frontmatter and argument hints, cross-plugin orthogonality and router coherence (`scripts/check-architecture-rules.js`), context imports and reference form, markdown links, the paths the prose names and a sentence written in two documents (`scripts/check-doc-duplication.js`); `cli` lint, architecture, typecheck and type honesty when `cli/` changed. `cli` knip and the full `cli` suite are pre-push, not pre-commit — see below | | 2 | `pnpm exec commitlint --edit` | the message against `commitlint.config.cjs` | Same hook regenerates each plugin's `CATALOG.md`, the README counts and `docs/prompts-documentation.md`, and stages them. @@ -43,7 +43,13 @@ Every `cli` job is globbed on `cli/**`. A change under `kanban/` alone fires no | ----- | ------- | ------ | | 1 | `pnpm exec lefthook run pre-push` | `cli knip`, then the full `cli` suite, when `cli/` changed | -`--no-verify` buys nothing: `validate.yml` re-runs the whole pre-commit over the whole tree on every push and pull request. +Each runs through `scripts/gate-witness.js`, which skips a gate this exact tree already passed: same index, same unstaged edits, same untracked files. A tree that changed while the gate ran is never stamped. + +`--no-verify` buys little, not nothing: + +- A push to a feature branch fires no workflow. +- Any pull request fires `ci.yml` and `cli-ci.yml`, whatever its base. +- Only a pull request targeting `main` or `next` replays the pre-commit, and `validate.yml` drops `cli-biome`, `cli-architecture` and `cli-typecheck` from it. ## Behavior diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md index c7a486b95..901f9cdd9 100644 --- a/aidd_docs/memory/deployment.md +++ b/aidd_docs/memory/deployment.md @@ -9,7 +9,7 @@ Where the project runs and how it ships: CI/CD, environments, and release. | Workflow | Runs | | --- | --- | | `ci.yml` | commitlint on pull requests and on `main`'s tip, plus the PR title itself — the subject a squash merge uses — then release-please on `main` and the release jobs | -| `cli-ci.yml` | the `cli` and `kanban` gates — job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do — `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself, and `plugins/aidd-telemetry/**` except its `*.md` prose | +| `cli-ci.yml` | the `cli` and `kanban` gates — job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do — `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself, and `plugins/aidd-telemetry/**` except its `*.md` prose. Mutations skip only for a same-repository numeric `promote/next-to-main-*` snapshot whose `cli / gate` passed in a successful `next` push and whose PR merge tree equals that snapshot with `main` already its ancestor; the resulting `main` push reuses it only for that exact two-parent promotion merge when its tree, associated merged PR, and source snapshot all match. Missing, failed, unreadable, or mismatched Git/API proof keeps normal mutation scopes. All non-mutation checks still run on the current PR merge ref or `main` commit. | | `validate.yml` | plugin and marketplace manifests against their schemas, plus the whole pre-commit over the whole tree | | `codeql.yml` | code scanning | | `promote.yml` | opens the `next` to `main` promote PR, merge auto-merge | @@ -44,13 +44,13 @@ None — no server, no container, no IaC. What ships are release assets and publ Branch model in `vcs.md`, cadence and safety rules in [`RELEASE.md`](../../RELEASE.md). -1. release-please opens the Release PR. Only paths with commits bump; the root bumps every cycle. CI auto-merges it with `--squash --admin`, because the branch policy refuses a plain merge, so `main` never holds merged but unversioned code. -2. Merging creates the release and its tags — a root umbrella tag, `cli-v`, and one `-v` per plugin, `include-component-in-tag: true`. +1. release-please opens the Release PR. Only paths with commits bump; the root bumps every cycle. CI auto-merges it with `--merge --admin`, the only method `.github/rulesets/main.json`'s `pull_request` rule allows, so `main` never holds merged but unversioned code. +2. Merging creates the release and its tags — a root umbrella tag, `cli-v`, and one `-v` per plugin, `include-component-in-tag: true`. `scripts/credit-release-authors.cjs` then appends each line's commit author as `(@login)`: a workaround until [googleapis/release-please#2892](https://github.com/googleapis/release-please/pull/2892) ships. 3. Release jobs: `build-and-attach` (marketplace bundle), `build-per-tool` (nine distributions), `build-plugin` (one archive per released path), `publish-cli`. 4. Archives are staged outside the repo tree, uploaded with `gh release upload --clobber`. 5. `back-merge.yml` folds `main` into `next`. -Config: `release-please-config.json`, ten packages. Manifest: `.release-please-manifest.json`. +Config: `release-please-config.json`, eleven packages. Manifest: `.release-please-manifest.json`. ## Gotchas diff --git a/aidd_docs/memory/project-brief.md b/aidd_docs/memory/project-brief.md index 781087fc7..80099b5b7 100644 --- a/aidd_docs/memory/project-brief.md +++ b/aidd_docs/memory/project-brief.md @@ -40,6 +40,7 @@ What this project is, the problem it solves, and its domain language. The non-de | Generate context artifacts | `aidd-context:03-context-generate` and its per-kind generators | | Development loop | `aidd-dev` — plan, implement, assert, audit, review, test, refactor, debug | | Typed product backlog | `aidd-pm` — brief, epic, story, spec, spike, defect | +| Acceptance QA evidence | `aidd-qa:01-acceptance-qa` | | Refine input and output | `aidd-refine` — brainstorm, challenge, blind spots | | End-to-end orchestration | `aidd-orchestrator:01-sdlc` | | Measure what a session cost | `aidd-telemetry`, opt-in, plus `aidd telemetry` | diff --git a/aidd_docs/memory/testing.md b/aidd_docs/memory/testing.md index 39acfa3e6..eb1b17575 100644 --- a/aidd_docs/memory/testing.md +++ b/aidd_docs/memory/testing.md @@ -13,7 +13,7 @@ How the project is tested: the layers, the tools, and the conventions. Where tes | `cli/` | vitest, four projects — see the CLI bank | | `kanban/` | its own vitest suite. It shares no code with `cli/` | | Per-tool distributions | golden snapshots in `cli/tests/golden/`, mirrored by the `build-per-tool` CI matrix; Claude Code's own `plugin validate` over a fresh claude build, in `cli-ci.yml` | -| Browser journeys | `aidd-dev:11-browser-qa`, see below | +| Browser journeys | `aidd-qa:01-acceptance-qa`, see below | ## Tools @@ -46,4 +46,4 @@ CI runs more: `validate.yml` re-runs the whole pre-commit over the whole tree on - Runner: `npx --yes @playwright/cli@0.1.17`, the framework pin. Never `latest` during QA. - Also required: `ffmpeg` and `ffprobe`. Output is WebM evidence per scenario. -- Owned by `aidd-dev:11-browser-qa`; this repository ships the capability, it has no browser journey of its own. +- Owned by `aidd-qa:01-acceptance-qa`; this repository ships the capability, it has no browser journey of its own. diff --git a/aidd_docs/memory/vcs.md b/aidd_docs/memory/vcs.md index 0b2387385..2f43a52a0 100644 --- a/aidd_docs/memory/vcs.md +++ b/aidd_docs/memory/vcs.md @@ -32,6 +32,11 @@ The version-control conventions this project follows: branches, commits, and the - The board does not advance on its own; it is moved by hand, by a human or an agent through `gh`. Board conventions are in `backlog.md`. - Automation owns `promote/*` and `back-merge/*`, which follow neither the format nor the table. +## Pull requests + +- A pull request stacked on another targets that branch, not `next`. Before merging the base, retarget the dependent one: `gh pr edit --base next`. GitHub closes a pull request whose base branch is deleted, unless the deletion is the merge's own, so never delete a branch another open pull request targets. +- A squash merge of the base leaves the dependent branch carrying the base's original commits, and it conflicts with `next`. Replay only its own commits: `git rebase --onto origin/next `, then push with `--force-with-lease`. + ## Commits - Convention: [Conventional Commits](https://www.conventionalcommits.org/), enforced by `commitlint.config.cjs`. **Read that file before composing a message; if this page and the config disagree, the config wins.** diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-1.md new file mode 100644 index 000000000..b3c42f37d --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-1.md @@ -0,0 +1,68 @@ +--- +status: done +--- + +# Instruction: Reuse a validated promotion snapshot + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── .github/ + └── workflows/ + └── cli-ci.yml ✏️ classify promotion snapshots, verify a prior next gate, and skip only the duplicate mutation matrix +``` + +## User Journey + +```mermaid +flowchart TD + A[system: bot opens promote/next-to-main snapshot PR] --> B{same SHA has successful push gate on next?} + B -- yes --> C[run normal merge-ref checks without mutations] + C --> D[cli / gate passes] + B -- no --> E[run normal CLI and selected mutation jobs] + E --> D +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + promotion snapshot with a successful next gate => workflow receives its head SHA: 5: system + section Happy path + trigger promotion PR => mutations skip while normal checks validate the merge ref: 5: system + section Edge case - missing proof + no successful next gate for the SHA => normal CLI and mutation jobs remain required: 5: system +``` + +## Tasks to do + +### `1)` Classify a trusted promotion snapshot + +> Prove the snapshot was already gated on `next`, fail closed otherwise. + +1. Add the least privilege needed to read workflow runs. +2. Query successful `push` runs on `next` for the exact PR head SHA and require the `cli / gate` job to have succeeded. +3. Expose an empty mutation scope list only after that proof; preserve normal scope selection otherwise. + +### `2)` Keep merge integration coverage + +> Remove only repeated source mutation testing from the PR merge ref. + +1. Skip only `cli-mutation` for a trusted promotion snapshot. +2. Preserve all existing non-mutation jobs and their gate wiring. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | Only a `promote/next-to-main-` PR whose exact head SHA already has a successful `push` `cli / gate` on `next` may set mutation scopes to empty. | +| 1 | Missing, failed, or unreadable validation proof does not skip mutations. | +| 2 | A trusted promotion still runs coverage, smoke, build, platform, and other non-mutation checks against GitHub's PR merge ref. | +| 2 | Ordinary pull requests retain their existing job and mutation behavior. | diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-2.md new file mode 100644 index 000000000..e015bb948 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-2.md @@ -0,0 +1,66 @@ +--- +status: done +--- + +# Instruction: Lock the workflow contract + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── scripts/ +│ └── __tests__/ +│ └── cli-ci-gate-covers-every-job.test.js ✏️ assert promotion reuse and required-gate coverage +└── aidd_docs/ + └── memory/ + └── deployment.md ✏️ describe promotion-specific mutation reuse +``` + +## User Journey + +```mermaid +flowchart TD + A[system: workflow configuration changes] --> B[static workflow contract test] + B --> C{promotion safety and gate fan-in hold?} + C -- yes --> D[CI configuration is valid] + C -- no --> E[descriptive test failure] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + CI workflow YAML and its contract test => workflow parsed: 5: system + section Happy path + run the workflow contract test => trusted promotion, fallback, retained merge checks, and gate wiring are asserted: 5: system + section Edge case - future job + add an ungated job => gate-coverage test fails: 5: system +``` + +## Tasks to do + +### `1)` Assert promotion safety structurally + +> Make regressions in the promotion fast path visible before merge. + +1. Extend the existing workflow contract test with the trusted-promotion and fallback invariants. +2. Assert a trusted promotion empties only mutation scopes and retains the existing gate fan-in. + +### `2)` Record the operating model + +> Keep deployment memory aligned with the workflow. + +1. Replace the generic CLI CI description with its promotion reuse rule and fail-closed fallback. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | The contract test fails if trusted promotion detection, mutation fallback, retained checks, or gate fan-in is removed. | +| 2 | Deployment memory accurately states when promotion skips mutations and what still runs. | diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-3.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-3.md new file mode 100644 index 000000000..d85d6e8e5 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-3.md @@ -0,0 +1,69 @@ +--- +status: done +--- + +# Instruction: Bind promotion reuse to the tested merge tree + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── .github/ +│ └── workflows/ +│ └── cli-ci.yml ✏️ fail closed when main is not already contained by the promotion snapshot +└── scripts/ + └── __tests__/ + └── cli-ci-gate-covers-every-job.test.js ✏️ lock the ancestry requirement +``` + +## User Journey + +```mermaid +flowchart TD + A[promotion snapshot PR to main] --> B{main base is ancestor of snapshot?} + B -- yes, next gate passed --> C[skip duplicate mutations] + B -- no --> D[run selected mutation scopes] + C --> E[run all merge-ref checks] + D --> E +``` + +## Test Scope + +```mermaid +flowchart TD + A[workflow contract] --> B{ancestry guard present before empty scopes?} + B -- yes --> C[contract passes] + B -- no --> D[contract fails] +``` + +## Wireframe + +```txt +No UI: GitHub Actions workflow behavior only. +``` + +## Tasks to do + +### `1)` Prove promotion content is unchanged + +> Reuse a `next` mutation result only when the PR merge cannot add untested main content. + +1. Read the promotion PR base and snapshot SHAs from the event. +2. Require the base SHA to be an ancestor of the snapshot before marking a promotion trusted. +3. Keep every failed or unreadable Git proof on the normal mutation path. + +### `2)` Lock the fail-closed guard + +> Make a future removal of the ancestry check fail locally. + +1. Extend the workflow contract test with the base-to-snapshot proof and fallback expectation. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | A promotion with uncontained `main` content cannot set `mutation_scopes=[]`. | +| 1 | A promotion whose base is contained by its exact snapshot and whose `next` gate passed retains the mutation skip. | +| 2 | The contract test fails when the ancestry proof or fail-closed fallback is removed. | diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-4.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-4.md new file mode 100644 index 000000000..7eeb9eea7 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-4.md @@ -0,0 +1,76 @@ +--- +status: done +--- + +# Instruction: Reuse the proven promotion merge on main + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── .github/ +│ └── workflows/ +│ └── cli-ci.yml ✏️ recognize a proven promotion merge on main and skip only duplicate mutations +├── scripts/ +│ └── __tests__/ +│ └── cli-ci-gate-covers-every-job.test.js ✏️ lock main-merge proof and unchanged fallback +└── aidd_docs/ + └── memory/ + └── deployment.md ✏️ document both trusted reuse paths +``` + +## User Journey + +```mermaid +flowchart TD + A[push merge commit to main] --> B{matching promotion PR?} + B -- no --> C[run selected mutations] + B -- yes --> D{merge tree equals proven snapshot and next gate passed?} + D -- no --> C + D -- yes --> E[skip duplicate mutations] + C --> F[run normal non-mutation checks] + E --> F +``` + +## Test Scope + +```mermaid +flowchart TD + A[workflow contract] --> B{promotion PR identity, tree equality, and next gate required?} + B -- yes --> C[contract passes] + B -- no --> D[contract fails] +``` + +## Wireframe + +```txt +No UI: GitHub Actions workflow behavior only. +``` + +## Tasks to do + +### `1)` Classify a trusted main promotion merge + +> Skip mutations on main only for the exact content already gated on next. + +1. Identify the merged promotion PR and its snapshot with read-only repository data. +2. Require the final main commit tree to equal that snapshot's tree. +3. Reuse only the successful `cli / gate` run for the snapshot's exact SHA on `next`. + +### `2)` Preserve normal behavior and explain it + +> Keep every ambiguous, failed, or unrelated main push fully protected. + +1. Route missing PR identity, unavailable API data, mismatched trees, and failed gates to normal mutation scope selection. +2. Assert the trusted-main contract structurally and update deployment memory. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | Only a merge from the numbered promotion branch whose final tree equals the snapshot may skip mutations on `main`. | +| 1 | The matching snapshot must have a successful `push` `cli / gate` on `next`. | +| 2 | Every missing, unreadable, mismatched, or unrelated proof preserves normal mutation execution. | +| 2 | Non-mutation jobs and gate fan-in remain unchanged for all events. | diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md new file mode 100644 index 000000000..ffc8c9e04 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md @@ -0,0 +1,39 @@ +--- +objective: "A next-to-main promotion and its resulting main merge reuse an already-passing mutation gate only when the exact tested tree is proven unchanged." +status: implemented +--- + +# Plan: Reuse validated next CI through promotion + +## Overview + +| Field | Value | +| --- | --- | +| **Goal** | Remove duplicate mutation matrices from promotion and its resulting `main` merge without weakening any gate. | +| **Source** | User request: strict SDLC after the CI-wide challenge. | + +## Phases + +| # | Phase | File | +| --- | --- | --- | +| 1 | Reuse a validated promotion snapshot | [`phase-1.md`](./phase-1.md) | +| 2 | Lock the workflow contract | [`phase-2.md`](./phase-2.md) | +| 3 | Bind promotion reuse to the tested merge tree | [`phase-3.md`](./phase-3.md) | +| 4 | Reuse the proven promotion merge on `main` | [`phase-4.md`](./phase-4.md) | + +## Resources + +| Source | Verified | +| --- | --- | +| https://docs.github.com/en/rest/actions/workflow-runs | Workflow runs can be filtered by branch, event, and head SHA with Actions read permission. | +| https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows | A pull-request checkout uses the synthetic merge ref, so a promotion-specific smoke can validate the merged result. | +| Repository evidence: PR #809 | Its merge tree equalled the promoted `next` snapshot, but that invariant was convention-only rather than enforced. | + +## Decisions + +| Decision | Why | +| --- | --- | +| Reuse only a successful `push` run for the exact snapshot SHA on `next`. | The promotion source has already satisfied `next`'s required gate; absent or unsuccessful proof must not bypass mutations. | +| Keep all non-mutation jobs on the promotion PR merge ref. | Coverage, smoke, build, and platform checks still validate the merge of release metadata from `main`. | +| Require the promotion PR base to be an ancestor of its snapshot before reuse. | A previously green `next` run is insufficient if `main` contributes untested content to the PR merge tree. | +| On a `main` push, require both the matching promotion PR and tree equality with its snapshot. | A matching source SHA alone cannot prove that the merge commit carries the same content. | diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md new file mode 100644 index 000000000..52d18a63d --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md @@ -0,0 +1,49 @@ +# Review: Reuse validated next CI through promotion + +- **Verdict**: approve +- **Diff**: `origin/next...92941865` +- **Axes run**: code, functional, relevancy +- **Date**: 2026_09_10 +- **Findings**: 0 critical, 0 warning, 0 minor + +## Phases + +### Phase 1 — Reuse a validated promotion snapshot + +- [x] Only a same-repository `promote/next-to-main-` PR whose exact head SHA has a successful `push` `cli / gate` on `next` may set mutation scopes to empty. — `.github/workflows/cli-ci.yml:110-145,185-202` +- [x] Missing, failed, or unreadable validation proof does not skip mutations. — `.github/workflows/cli-ci.yml:107-131,136-145,185-202` +- [x] A trusted promotion still runs coverage, smoke, build, platform, and other non-mutation checks against GitHub's PR merge ref. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:120-140` +- [x] Ordinary pull requests retain their existing job and mutation behavior. — `.github/workflows/cli-ci.yml:136-145,185-202` + +### Phase 2 — Lock the workflow contract + +- [x] The contract test fails if trusted promotion detection, mutation fallback, retained checks, or gate fan-in is removed. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:63-140` +- [x] Deployment memory accurately states when promotion skips mutations and what still runs. — `aidd_docs/memory/deployment.md:12` + +### Phase 3 — Bind promotion reuse to the tested merge tree + +- [x] A promotion with uncontained `main` content cannot set `mutation_scopes=[]`. — `.github/workflows/cli-ci.yml:136-145,185-202` +- [x] A promotion whose base is contained by its exact snapshot and whose `next` gate passed retains the mutation skip. — `.github/workflows/cli-ci.yml:136-145` +- [x] The contract test fails when the ancestry proof or fail-closed fallback is removed. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:84,115-117` + +### Phase 4 — Reuse the proven promotion merge on `main` + +- [x] Only a two-parent merge from the numbered, same-repository promotion branch whose final tree equals the snapshot may skip mutations on `main`. — `.github/workflows/cli-ci.yml:149-175,185-202` +- [x] The matching snapshot must have a successful `push` `cli / gate` on `next`. — `.github/workflows/cli-ci.yml:110-131,170-172` +- [x] Every missing, unreadable, mismatched, or unrelated proof preserves normal mutation execution. — `.github/workflows/cli-ci.yml:107-108,149-175,185-202` +- [x] Non-mutation jobs and gate fan-in remain unchanged for all events. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:12-27,120-140` + +## Findings + +| Sev | Kind | Phase | Location | Issue | Fix | +| --- | --- | --- | --- | --- | --- | +| — | — | — | — | None. | — | + +## Verification + +| Metric | Value | +| --- | --- | +| Verified | 100% (13/13) | +| Files checked | `.github/workflows/cli-ci.yml`, `scripts/__tests__/cli-ci-gate-covers-every-job.test.js`, `aidd_docs/memory/deployment.md`, `.github/workflows/promote.yml`, `.github/rulesets/main.json`, `.github/rulesets/next.json`, `phase-1.md`, `phase-2.md`, `phase-3.md`, `phase-4.md` | +| Unchecked | none | +| Unplanned | none | diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/challenge.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/challenge.md new file mode 100644 index 000000000..bd4a19f6a --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/challenge.md @@ -0,0 +1,17 @@ +My confidence level of correctness now: 68% + +# Correctness (100%) + +- The A/B ownership and foreign-state behavior is backed by 6,464 passing tests, byte-for-byte collision smoke, and an independent checker with no remaining confirmed security blocker. +- The core #829 behavior is supported, but the strict delivery claim is not correct yet: `framework` mutation scored 86.7% against its declared 93% floor, while 13 final-tree scopes remain unchecked. The user authorized a partial draft, not a green gate or merge. +- Copilot 1.0.83 fails closed on native source proof. That preserves existing state but does not deliver fresh native activation on that installed version. + +# Deal breakers + +- Do not mark the issue closed or the draft PR merge-ready under strict SDLC. At least 515 additional framework mutants must be detected to meet the existing floor; targeted witnesses or a simpler proof flow need a new validated candidate. +- Partial Copilot 1.0.83 functionality must remain explicit in the draft. Native fresh activation is not verified, so end-to-end satisfaction remains uncertain. + +# Suggestions (enhancements only) + +- Inventory changed-line mutants before the expensive whole-scope campaign, then test each negative and positive ownership branch; retain the whole-scope gate for the final tree. +- Keep a single source/test SHA and Node/HOME fixture contract through full gates and mutation. Reuse a green report only when those inputs are identical. diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/frame.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/frame.md new file mode 100644 index 000000000..8da930c2c --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/frame.md @@ -0,0 +1,11 @@ +# #829 Check follow-up contract + +Source: [issue #829](https://github.com/ai-driven-dev/framework/issues/829), `review.md`, and the second read-only preflight (blocked, 2026-09-15). The user will handle legacy state manually. + +1. Project sync never refreshes a host marketplace without proving the exact host catalogue is AIDD-owned and contains no foreign plugin refs. No unnamed/global host refresh. A project-labelled catalogue is not proven by a plugin claim alone; without canonical catalogue provenance, skip refresh, preserve host state, and name the limitation. Fresh install must still activate AIDD's plugin without taking over a preexisting catalogue. +2. A conflicting reserved `aidd-framework` host catalogue is never force-removed or rebuilt without canonical AIDD ownership and absence of foreign refs, even when the host calls its registration dead or unknown. Otherwise refuse and leave host state intact. +3. Project `marketplace remove` and `uninstall plugin` remove only A's project-local hooks/scripts and any tool-specific local MCP projection created by that flow before deleting A's manifest projection or detaching A's machine claim where one exists. Cursor's MCP file is user-global, not project-local, and stays for B. Failure retains the truthful local projection and any existing machine claim for a safe retry; B and all machine-owned files/refs remain intact. Do not invent an OpenCode machine claim. +4. A prior AIDD hostName/ref claim is not proof of the current host catalogue source. Native sync, targeted update/remove, and project/user clean compare the effective source read from the host with exact AIDD provenance before any host mutation. Even an owned catalogue may contain a foreign installed ref: catalogue removal also requires the full host ref list and refuses a noncanonical ref. Fresh absence is read from the host; an unreadable/unsupported source fails closed. The installed Copilot 1.0.83 lacks documented structured listing, so it must not activate partially or fabricate ownership. +5. A name/path is not proof of current local file contents. Use installed digests for Cursor user-scope files, OpenCode MCP entries, and Cursor project hook entries/scripts before update/unmerge/delete; a changed or legacy-unproven contribution remains in place and retains truthful claims for manual reconciliation. + +Proof: test-first A/B and foreign fake-HOME host assertions, targeted mutation of each destructive guard, then all required CLI gates and a fresh independent Check. No automated legacy migration, no floor reductions, no force bypass. diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-1.md new file mode 100644 index 000000000..687669de8 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-1.md @@ -0,0 +1,64 @@ +--- +status: in-progress +--- + +# Instruction: guard native activation + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ + src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts ✏️ provenance before upgrade/reclaim + src/contexts/tools/domain/ports/native-plugin-activator.ts ✏️ exact catalogue refresh contract if supported + src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts ✏️ exact host CLI dispatch if supported + tests/contexts/framework/application/flows/ ✏️ foreign collision and A/B activation + tests/contexts/tools/infrastructure/ ✏️ exact CLI process arguments + scripts/smoke-collision.sh ✏️ catalogue and cache witnesses +``` + +## User Journey + +```mermaid +flowchart TD + Fixture["Foreign catalogue and plugin in fake HOME"] --> Sync["A syncs its plugin"] + Sync --> Choice{"AIDD proves catalogue ownership?"} + Choice -- "No" --> Refuse["Name collision; foreign state unchanged"] + Choice -- "Yes" --> Exact["Refresh exact AIDD catalogue only"] +``` + +## Test Scope + +```mermaid +--- +title: Native activation test scope +--- +journey + section Setup + Seed foreign catalogue and cache => witness hashes recorded: 5: cli + section Happy path + Sync AIDD owned exact catalogue => only that catalogue refreshes: 5: cli + section Edge case - foreign collision + Foreign same name or ref => sync refuses and hashes stay equal: 1: cli + section Edge case - stale registration + Unproven dead host registration => no force remove or rebuild: 1: cli + section Teardown + Clean fake HOME => real HOME unchanged: 5: cli +``` + +## Tasks to do + +### `1)` Prove ownership before host mutation + +> No unscoped refresh or forced takeover can run on foreign state. + +1. Write failing fake-host collision tests and targeted mutation checks. +2. Limit refresh to exact proven host catalogues; refuse when the host cannot target safely. +3. Guard reserved-name reclaim with canonical ownership and no foreign refs; update older migration expectations. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | A sync preserves foreign catalogue registration, plugin ref, and cache bytes; only a proven AIDD catalogue changes, with exact host arguments. | diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-2.md new file mode 100644 index 000000000..5acff6950 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-2.md @@ -0,0 +1,64 @@ +--- +status: in-progress +--- + +# Instruction: finish project-local removal + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ + src/contexts/framework/application/flows/marketplace-remove-use-case.ts ✏️ local cleanup before orphan detach + src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts ✏️ local cleanup before uninstall detach + src/contexts/framework/application/ownership/project-plugin-cleanup.ts ✏️ reuse or narrow shared cleanup + tests/contexts/framework/application/flows/ ✏️ Cursor hook and MCP orphan removal + tests/contexts/framework/application/ ✏️ uninstall failure and B survival +``` + +## User Journey + +```mermaid +flowchart TD + Shared["A and B share a machine plugin"] --> Remove["A removes a marketplace or uninstalls"] + Remove --> Local["Remove A's local hooks and any local MCP projection"] + Local --> Choice{"Local cleanup succeeded?"} + Choice -- "No" --> Retain["Keep truthful local projection and any existing claim"] + Choice -- "Yes" --> Detach["Detach A; B and machine plugin remain"] +``` + +## Test Scope + +```mermaid +--- +title: Project removal test scope +--- +journey + section Setup + Install shared Cursor plugin in A and B => local hook and global MCP witnesses ready: 5: cli + section Happy path + Remove from A => A integration gone and B still works: 5: cli + section Edge case - local failure + Hook or MCP delete fails => A projection and any claim stay for retry: 1: cli + section Edge case - tool-specific MCP + Remove OpenCode flat plugin => only A's local MCP entry unmerged: 1: cli + section Teardown + Remove B then user clean => machine plugin removed only after both detach: 5: cli +``` + +## Tasks to do + +### `1)` Complete local cleanup before detachment + +> Do not leave A's local projections behind without a plugin record; do not remove Cursor's global MCP. + +1. Write failing `marketplace remove` and `uninstall plugin` integration tests. +2. Reuse project-local cleanup for hooks/MCP before manifest removal and claim detach. +3. Prove failure retains A's claim and B's global plugin remains intact. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | Both commands remove A's project hooks/scripts and any local MCP projection created by that flow before detach; failure retains A's projection and any existing machine claim; Cursor's global MCP, B, and machine-owned state remain unchanged. | diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-3.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-3.md new file mode 100644 index 000000000..6581179e9 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-3.md @@ -0,0 +1,26 @@ +--- +status: in-progress +--- + +# Instruction: prove current host source + +## Outcome + +For Codex/Copilot, read the effective host catalogue source before add, refresh, update, project removal, or project/user clean. An AIDD machine claim alone cannot authorize a mutation when the source has been repointed. Before unregistering even an owned catalogue, prove its full host ref list contains no foreign ref. Fresh absence permits registration; an unreadable or foreign source refuses with a named manual remedy. Record exact source only after a proven fresh add. No legacy claim inference. + +Apply this gate to project plugin removal even when an old manifest lacks a native registration, if that removal would reach the host. Project clean may still detach a shared user-scope claim and clean local state without catalogue proof, because it must not mutate that shared catalogue. An unproven plugin ref is left enabled rather than uninstalled. [Copilot repository `enabledPlugins` is declarative auto-install](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference), so even writing settings is an activation side effect: defer that projection until the catalogue and plugin ref are proven and activated. + +Before `clean --scope user`, map every canonical machine claim to exactly one registered host catalogue with current source/ref proof. An orphan or ambiguous claim cannot be uninstalled through a different proven catalogue. A machine-global ref without a canonical claim is likewise not exclusive to this project, even if its host source is AIDD-owned. + +User clean preflights availability of every native activator it will need before uninstalling the first tool. A known missing second binary must not leave the first tool already changed while machine claims still record the old state. + +Codex 0.151.0 exposes `marketplace list --json` with `name`, `root`, and `marketplaceSource`. The installed Copilot 1.0.83 refuses `--json` despite current official docs; its repository overlay can replace the user source. Do not parse human text as an ownership proof. Feature-probe structured output and fail closed on unsupported versions until an exact source witness can be measured. + +## Verification + +- A/B fresh registration works; a foreign same-name source and cache stay byte-identical through sync/update/remove/clean. +- Copilot activation refusal leaves foreign repository settings, including `extraKnownMarketplaces` and `enabledPlugins`, byte-identical. +- Native CLI readers and source parsing use installed binary/official contracts, fail closed on unknown shapes. +- User clean refuses an orphan `x@B` claim when only catalogue A is proven; project clean never uninstalls an unclaimed machine-global ref used manually by B. +- Multi-tool user clean with the second binary unavailable refuses before the first host catalogue or ref changes. +- Full tests, architecture, build, smoke, targeted destructive mutants, then all declared mutation scopes. diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-4.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-4.md new file mode 100644 index 000000000..ff31a14b0 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-4.md @@ -0,0 +1,19 @@ +--- +status: in-progress +--- + +# Instruction: preserve edited local integration + +## Outcome + +OpenCode MCP and Cursor project hooks/scripts are user-owned after installation. Compare each current contribution with its recorded install digest before unmerge or deletion. Edited or legacy-unproven entries refuse local detach and preserve claims for manual reconciliation. Reinstall must treat edited MCP as a user collision. Do not delete an entire script directory by plugin name. + +Content digest is not path confinement: reject a hooks config, script/parent directory, or MCP output whose resolved path leaves canonical projectRoot, even if the external bytes match the install digest. Preflight before native/shared claims move, then revalidate immediately before write/delete. + +## Verification + +- A/B remove and clean preserve B and edited project integration; unedited A contributions are removed precisely. +- A failed read/write or digest mismatch leaves A projection/claim truthful and retry-safe. +- Project clean preflights edited hooks before dropping a shared-source reference or undoing any native registration; removal rechecks immediately before write. +- Symlinked hooks config, script parent, and MCP output outside the project remain byte-identical; claims and B remain untouched. +- Targeted RED→GREEN tests and destructive mutation witnesses; full gates run on integrated tree. diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-5.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-5.md new file mode 100644 index 000000000..e78cc03bc --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-5.md @@ -0,0 +1,24 @@ +--- +status: in-progress +--- + +# Instruction: guard user-scope files + +## Outcome + +Before plugin update, remove, marketplace remove, or global user clean touches a Cursor user-scope file, compare current bytes with the installed digest already in the manifest. A changed, unreadable, or legacy-unproven file blocks that file's mutation and keeps ownership records truthful for manual reconciliation. Containment checks remain mandatory but are not content proof. + +On update, a new path absent from the previous owned-file list must also be absent on disk. An existing user-created path is not an AIDD file merely because a later plugin version wants to write it. + +For a plugin with both native refs and user files, prepare every file update and validate all old digests/new-path collisions before any native update. Applying a validated plan can still fail through unexpected I/O; retain truthful claims and name manual reconciliation rather than assert cross-host atomicity. + +Recheck all planned old paths just before the file write. If a tracked path or parent becomes a symlink outside the user plugin root between planning and application, refuse rather than writing through it; a reduced safe-file map is not permission to continue. + +## Verification + +- Edited `plugin.json`/script octets survive all four operations; an unedited A contribution is still removable without harming B. +- A user-created `new.md` that collides with a new plugin-version file survives update byte-identically. +- A mixed native+file update collision causes no host call, no file change, and no claim change. +- A symlink substituted after planning cannot overwrite external bytes, even if the native host update has already succeeded. +- A partial failure does not detach a project or machine claim while its local state remains unproven. +- Targeted tests and destructive mutant witnesses, then the integrated full gates. diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/plan.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/plan.md new file mode 100644 index 000000000..2f98eeb21 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/plan.md @@ -0,0 +1,44 @@ +--- +objective: "Project A can install or remove a plugin without changing foreign machine state or stranding A's local integration while B remains dependent." +status: in-progress +--- + +# Plan: #829 Check repairs + +## Overview + +| Field | Value | +| --- | --- | +| **Goal** | Prove native catalogue ownership before host mutation; complete local cleanup before claim detach. | +| **Source** | [#829](https://github.com/ai-driven-dev/framework/issues/829), [`frame.md`](./frame.md), [`review.md`](./review.md) | + +## Phases + +| # | Phase | File | +| --- | --- | --- | +| 1 | Guard native activation | [`phase-1.md`](./phase-1.md) | +| 2 | Finish project-local removal | [`phase-2.md`](./phase-2.md) | +| 3 | Prove current host source | [`phase-3.md`](./phase-3.md) | +| 4 | Preserve edited local integration | [`phase-4.md`](./phase-4.md) | +| 5 | Guard user-scope files | [`phase-5.md`](./phase-5.md) | + +## Resources + +| Source | Verified | +| --- | --- | +| `codex plugin marketplace upgrade --help` | No name updates all configured Git marketplaces. | +| `copilot plugin marketplace update --help` | No name updates all registered marketplaces. | +| [`docs/ARCHITECTURE.md`](../../../../docs/ARCHITECTURE.md) | Host CLI calls stay behind the native activator port; application owns provenance. | + +## Decisions + +| Decision | Why | +| --- | --- | +| Refuse an unproven host catalogue; no `force` takeover | #829 requires preexisting user state untouched, including same-name collisions. | +| No unproven native legacy migration | Local registry scope normalization can continue; ambiguous host state is for manual reconciliation. | +| Skip automatic refresh of unproven project-labelled catalogues | A plugin claim does not prove machine catalogue ownership; preserve foreign state before optimizing refresh. | +| Local cleanup before machine claim detach | A failure must leave a truthful claim so retry cannot harm B. | +| A past machine claim is not current host source proof | Same-name catalogues can be repointed after AIDD registration; refuse host mutations without a current source witness. | +| Current source proof is not full ref ownership | An AIDD-owned catalogue can contain foreign host refs; read and partition host refs before unregistering it. | +| Compare local integration to install digests | Hooks and MCP in user-owned project files may be edited; never remove by name alone. | +| Compare user-scope files to install digests | Path containment is not ownership of current contents; edited files must not be updated or deleted by explicit user-scope operations. | diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/retrospective.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/retrospective.md new file mode 100644 index 000000000..22916e893 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/retrospective.md @@ -0,0 +1,51 @@ +# #829 delivery retrospective + +## Observation and limits + +This records the delivery after the request to implement #829 from `origin/next`. The original dirty checkout was not edited; implementation used `framework-829`. Measurements below come from 141 completed command logs dated 2026-09-15 13:58:50–19:07:39 UTC. That 5 h 08 m 49 s window is not the total task duration, and overlapping command durations must not be added to infer elapsed wall time. Final gate results and later work must be appended before closing this report. + +## Measured verification cost + +| Completed runs | Cumulative command time | Meaning | +| --- | ---: | --- | +| 30 Stryker campaigns | 45 m 02 s | 11 framework runs: 26 m 36 s; five changed-lines runs: 9 m 38 s; 14 other targeted runs: 8 m 48 s. | +| 17 full Vitest passes, excluding Stryker dry-runs | 9 m 06 s | Mean 32.17 s. Ten green passes after the fourth delivery candidate: 5 m 17 s across different trees, not ten equivalent proofs. | +| First framework mutation campaign | 6 m 44 s | Score 90.1%, below the required 93% floor. Ten subsequent framework relaunches consumed another 19 m 52 s before scores near 93.0–93.1%. | + +A later global Stryker attempt aborted at dry-run after 49 s and 4% progress. Reused mutants and static mutants did not make it a valid final report. The team stopped further campaigns until the source and test contract were stable. + +## Bottlenecks and their causes + +1. The initial destructive-effect matrix omitted native same-name collisions, current effective source changes, foreign host references under an AIDD-owned catalogue, edited project hooks/MCP entries, and edited user-scope files. The first independent checker and the next preflight found these after expensive green gates. A third preflight found `clean` overblocking a shared catalogue, project `plugin remove` still able to uninstall a repointed ref, a new user-created file still able to be overwritten on update, and [Copilot's repository `enabledPlugins` declarative auto-install](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference) possible before the CLI activation refusal. Earlier passing gates were real for their candidate, but not proof for the repaired candidate. +2. The proof interface changed while old fixtures were being adapted. One change yielded 84 red flow tests; moving the `references.json` write until *proven* registration reopened four reference tests. The contract should have been frozen before bulk fixture work. Positive test doubles must express independently observed host state, not copy the project's claims into the host reader. +3. The environment was not pinned end to end. Installed Homebrew Node 25 failed to start because `libsimdjson.30.dylib` was missing. Pinning only the parent Node executable was insufficient; spawned tools also needed a Node 24-first `PATH`. A smoke pass reported green while `md5` was missing and both fingerprints were empty. Replacing it with Node SHA-256 exposed a Bash 3.2 empty-array failure, then fixed it. Smoke now hashes path *and content*, rejects failed hash computation, and runs under the system Bash. +4. Mutation reports were sometimes compared after edits or a changed mutant inventory. Stryker's runner reuse and static `HOME` test fixtures also produced false survivors. Dependency-injected home lookup and targeted single-runner tests corrected this, but they do not validate an old report on a new tree. Mutation output must be bound to an immutable source hash and fixed environment. +5. Bundle growth was measurable rather than free: first candidate 681.8/682 KB, repaired source/local guards 703.8/710 KB, later path guards 714.2 KB. The final measured budget is 722 KB, 1.1% headroom. This is a reviewable cost of the added guards, not a reason to loosen another gate. +6. The late checker pass still found an ordering hazard: project clean detached the shared source claim before local file cleanup completed. A later local error could leave the project installed but unclaimed, making user-scope clean appear safe. The Copilot 1.0.83 source listing cannot supply structured proof, so fresh native registration remains intentionally unavailable on that installed version. These are separate findings: the first requires a code repair and failure-injection test; the second requires an explicit functional caveat, not fabricated native source data. + +## Next delivery loop, without relaxing quality or determinism + +1. Before costly checks, inventory the **installed** host binaries and their documented structured outputs. Run a fake-home, byte-for-byte foreign-state matrix for `sync`, targeted update/remove, uninstall, project clean, and user clean. Include A/B sharing, current source, all host refs, modified hooks/MCP, existing new-path collisions, and indirect repository settings effects. Fail closed where Copilot 1.0.83 cannot prove its current source; do not invent a text parser or upgrade the user's binary. +2. Freeze the ports, provenance schema, operation ordering, and reference-write point. Adapt legacy fixtures once, with explicit future verified-reader test doubles separate from installed-binary E2E checks. Run targeted red→green tests for each destructive branch and compare untouched paths and contents, not only exit codes. +3. On a frozen tree, run full Vitest, coverage floors, architecture ratchets, typecheck, lint, duplicate/dead-code checks, build budget, multi-tool smoke and collision smoke. An independent read-only preflight must clear all destructive paths before mutation. A source edit invalidates the complete-tree result for that path; rerun the relevant gates before proceeding. +4. Run targeted destructive mutants, then all 14 mutation scopes **exclusively** on the frozen source hash under the same Node 24-first `PATH`, `HOME`, and `CODEX_THREAD_ID` fixture contract. Preserve the existing mutation floors; never promote an aborted, stale, or wrong-hash report. A final independent checker inspects both behavior and evidence before delivery. + +No saved-time estimate is justified yet: the data measures repeated work, not the counterfactual runtime of the improved loop. The proposed gain is fewer invalidated expensive passes, not fewer acceptance gates. + +## Pre-remedy checkpoint, not a completed delivery + +The staged diff before the safe-remedy wording change had SHA-256 `e9cab56dbcb0f4a3a164a7e664620439e178d2de84f213c74f6bb8db111e63b6`. Full Vitest passed 521/521 suites and 6,464/6,464 tests in 35.44 s. Coverage passed the same 6,464 tests in 44.25 s: 98.92% lines, 96.51% branches, 99.68% functions. Architecture 131/131, typecheck, lint, knip, jscpd, build 714.3/722 KB, 33/33 command smoke, and foreign-state collision smoke all exited zero. The independent checker found no remaining confirmed security blocker in the implementation, but warned that Copilot 1.0.83 cannot prove a fresh native catalogue source and therefore refuses native activation even for a new profile. The wording change creates a new source SHA; these gates must not be presented as proofs of that newer tree until rerun. + +The `framework` mutation scope on that pre-remedy SHA completed in 6 m 49 s with **86.7%**, below its declared **93%** floor. Its report has 7,127 killed, 11 timed out, 895 survived, and 196 uncovered mutants: 8,229 scored mutants, 1,091 undetected. At least 515 more must be detected to reach 93%. Of the undetected mutants, 747 lie on lines changed from `origin/next`; 239 are in `marketplace-sync-settings-use-case.ts`, 108 in `plugin-remove-use-case.ts`, and 64 in `clean-use-case.ts`. The report is real for that earlier tree, not for the later wording-only SHA, and cannot be promoted to green by reusing a prior candidate's score. The remaining 13 scopes were not run after the first required floor failed. No final repairs had been committed or pushed and no draft PR had been opened at this checkpoint. + +The account-wide Codex weekly window read 80% used at the last check; no pre-task baseline exists, so attributing that percentage to this delivery would be false. There are no reset credits. This budget constraint does not change the mutation floor; it makes an explicit product decision necessary before any urgent partial delivery. + +## What the late failure changes in the proposed loop + +The early preflight should include a mutation-capacity estimate on **changed lines** before 14 complete scopes, not a substitute for them. A full-framework run should occur before polishing delivery documents, and its mutant inventory must be checked against added safety branches. This candidate added extensive fail-closed paths whose ordinary and collision tests verify outcome, yet often do not distinguish mutated guards. The remedy is focused negative and positive witness tests around each decision boundary, and possibly simpler control flow where branches duplicate a proof; not raising a threshold, mutating fewer files, or calling the code done on a below-floor score. We cannot quantify how long 515 additional detections would take from current measurements. + +## Authorized partial-draft checkpoint + +The user authorized a partial draft only if the original #829 preservation behavior is correct. A fresh independent checker compared the staged source with the issue's literal Cursor/Codex/Copilot collision and clean cases, reran 127 targeted tests on eight files, and found no remaining confirmed blocker to those preservation outcomes. This is not certification of the undetected mutants or of fresh Copilot 1.0.83 activation. The checker assigned 80/100 for a draft, not merge approval. + +After replacing an inherited destructive, Claude-specific conflict remedy with a host-neutral manual-reconciliation warning, the new source tree passed 521/521 suites and 6,464/6,464 tests in 37.05 s; coverage passed the same tests in 39.87 s at 98.92% lines, 96.51% branches and 99.68% functions. Build measured 714.3/722 KB, command smoke exercised 33/33 leaves with no failure, and foreign-state collision smoke had no failure. The earlier 86.7% framework mutation report does **not** measure this later wording-only tree. Under the explicit waiver, a draft PR may be prepared, but it must not be merged, #829 must not be closed, and the declared mutation floor remains 93% until a fresh campaign meets it. diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/review.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/review.md new file mode 100644 index 000000000..3fe5702e9 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/review.md @@ -0,0 +1,89 @@ +# Review: #829 shared user-scope plugin safety + +- **Verdict**: blocked +- **Diff**: `origin/next...codex/fix-829-shared-scope`; mutation hardening published in `58b5e92b`, CI corrections under verification +- **Axes run**: code, functional, relevancy +- **Date**: 2026_09_17 +- **Findings**: 1 critical, 2 warnings, 0 minor + +## Phases + +### Phase 1 — Guard native activation + +- [x] Sync preserves foreign catalogue source, plugin ref, and cache bytes; refresh targets only proven AIDD catalogues — `cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts:775`, `cli/scripts/smoke-collision.sh:69`. + +### Phase 2 — Finish project-local removal + +- [x] Marketplace removal and plugin uninstall clean A's local integration before detachment, retaining claims on local failure and leaving B/machine files intact — `cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts:106`, `cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts:72`. + +### Phase 3 — Prove current host source + +- [ ] Fresh native registration across A/B is partial: Codex has structured source proof, but Copilot 1.0.83 cannot activate even on a new profile — `cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts:4`. +- [x] Copilot refusal leaves foreign repository settings and native state untouched — `cli/scripts/smoke-collision.sh:81`. +- [x] Native readers use verified structured shapes and refuse unsupported outputs — `cli/src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.ts:45`. +- [x] User clean rejects orphan/unclaimed machine-global refs and project clean leaves another user's ref enabled — `cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts:316`, `cli/src/contexts/framework/application/clean-use-case.ts:248`. +- [x] Multi-tool user clean checks required binary availability before the first host mutation — `cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts:1158`. +- [ ] Final-head CI remains pending. Local `framework` passed at 93.5837%; the preceding published head passed twelve other mutation scopes in CI, but `tools-codex` failed at 90% against its 94% floor — `cli/mutation-scopes.json`. + +### Phase 4 — Preserve edited local integration + +- [x] Edited Cursor hooks/scripts and OpenCode MCP contributions are kept; clean/remove only unedited A entries without touching B — `cli/src/contexts/framework/application/shared/remove-project-hooks.ts:79`, `cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts:1`. +- [x] Failed local cleanup preserves A's reference and blocks shared user clean — `cli/tests/contexts/framework/application/clean-use-case.unit.test.ts:796`. +- [x] Hook preflight precedes source-claim detachment and host changes — `cli/src/contexts/framework/application/clean-use-case.ts:204`. +- [x] Symlinked project integration outside the canonical root is refused without changing external bytes — `cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts:62`. +- [x] Targeted behavior tests, full functional gates, and the whole-framework mutation floor pass at 93.5837% — `cli/reports/mutation/framework/mutation.json`. + +### Phase 5 — Guard user-scope files + +- [x] Edited user plugin files survive update, remove, marketplace remove, and user clean; A's unedited contribution can still detach — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:257`, `cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts:112`. +- [x] A user-created new-path collision is refused before update overwrites it — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:279`. +- [x] A mixed native/file collision prevents host call, file writes, and claim movement — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:305`. +- [x] A substituted symlink is rechecked before file I/O; external bytes survive deterministic tested cases — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:332`. +- [x] Partial update failure retains truthful claims for manual reconciliation — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:380`. +- [x] Targeted behavior tests, integrated functional gates, and the whole-framework mutation floor pass at 93.5837% — `cli/reports/mutation/framework/mutation.json`. + +## Findings + +| Sev | Kind | Phase | Location | Issue | Fix | +| --- | --- | --- | --- | --- | --- | +| 🔴 critical | functional | 3 | `cli/mutation-scopes.json` | Framework hardening is published in `58b5e92b`, with a local 93.5837% result and matching source. The preceding head passed twelve other mutation scopes in CI; Codex, Smoke, and Windows failed. Final-head gates are not yet certified. | Correct the failing contracts and verify CI on the final published head before a merge-ready verdict. | +| 🟡 warning | functional | 3 | `cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts:4` | Copilot 1.0.83 safely refuses even fresh native activation; #829's preservation need is met, but end-to-end Copilot install is partial. | Verify a structured source reader on a supported binary in a separate follow-up before claiming full support. | +| 🟡 warning | functional | 5 | `cli/src/contexts/framework/application/plugin/plugin-helpers.ts:58` | User clean validates paths with an injected home, but deletion resolves OS home. Normal OS-home deletion passes; custom injected-home deletion is not certified. | Align validation and deletion home resolution in a bounded follow-up with a custom-home witness. | + +## Verification + +| Metric | Value | +| --- | --- | +| Verified | 89.5% (17/19) | +| Files checked | `cli/src/contexts/framework/application/{flows,clean,plugin,ownership,shared,uninstall}`, `cli/src/contexts/tools/{domain,infrastructure}`, relevant E2E/integration tests, `cli/scripts/smoke-collision.sh`, task plan and issue #829 | +| Unchecked | Phase 3 fresh Copilot — follow-up; final-head CI, including Codex mutation, Smoke, and Windows — verification pending. Twelve other mutation scopes passed on the preceding head, not a fresh certification of the final tree. Custom-home cleanup is a separate diagnostic finding. | +| Unplanned | Measured bundle-budget increase and smoke harness adaptation support delivery verification; no unrelated feature found. | +| Draft disposition | User-authorized partial draft only; do not merge or close #829 until the strict gates and Copilot scope decision are resolved. | + +## Incremental local test hardening — 2026_09_16 + +- Tests only: 236 additional cases since the 6,464-test snapshot; production source is unchanged during these lots. No commit or push. +- Latest functional run: `pnpm exec vitest run --reporter=dot` passed 524 files / 6,700 tests in 35.16s. Coverage was last measured before the 143 latest tests: 99.09% lines, 96.80% branches, 99.68% functions, with 6,557 tests passing in 74.95s. `pnpm typecheck`, `pnpm lint`, and `git diff --check` passed; lint retains only the existing unused constructor-property warning at `uninstall-use-case.ts:33`. +- Twenty-one targeted reports under `cli/reports/mutation/829-*/mutation.json` show 465 unique additional detections against the original baseline, without double counting overlapping campaigns. The original full report and incremental state are preserved under `cli/reports/mutation/framework-baseline-829/`; the preceding 90.0595% snapshot is preserved under `cli/reports/mutation/framework-90-before93/`. +- The final whole-framework incremental run used the existing runner's exported scope arguments, pruning, scoring, and floor check, with concurrency limited to two. It reused 5,954 results, replayed 2,275 mutants, and finished in 9m01s: 7,688 killed, 13 timed out, 465 survived, 63 uncovered; score 93.5836675173168% on 8,229 mutants. The declared 93% gate passed. All 115 reported source files match the frozen working tree; production source, mutation configuration, exclusions, and scripts were unchanged during hardening. +- Exact comparison with the original baseline gives 563 additional detections and no detection regression. Two unmatched mutation keys concern the previously documented sync warning wording. Against the preceding 90.0595% snapshot, 291 newly detected mutants and one detection loss give a net gain of 290. The retained loss is a static empty-string mutant in `plugin-distribution-reader-adapter.ts:20`; it was also undetected in the original baseline. Its status is not replaced with an earlier favorable result. The framework gate is passed, but the strict overall delivery verdict remains blocked by the other thirteen scopes and the documented partial Copilot verification. +- Covered additions: post-add source/root/type mismatches and unreadable proof, local alias versus host identity, project versus machine catalogue proof, exact attachment to an already-enabled machine ref while preserving B, exclusive-access update preflight, affected-project warnings, and original I/O error causes. + +## Publication and CI corrections — 2026_09_17 + +- User-authorized publication: `58b5e92b` and `3946be7f` pushed to PR #870 with hooks active. Pre-commit architecture and lint, commitlint, and pre-push Knip and the full functional suite passed. The latest suite passed 525 files / 6,726 tests. The PR remains draft; no merge or issue closure. +- Windows reproduction: the file doubles registered `/project/link`, while callers used a resolved drive-qualified path. Two isolated Windows-path regression tests failed before correction. Symlink and `realpath` fault keys now resolve paths consistently; file-content keys remain unchanged. The focused four-file run passed 57 tests, including external-symlink refusal and project-boundary cases. +- Smoke now selects the native refusal contract only when Copilot is available; without it, local removal must succeed and warn that the native CLI is unavailable, followed by local reinstall. Both available-host and missing-host runs passed all 33 command families with zero failures. The native refusal run preserved exact project/home bytes. +- The previous Codex report matched its six source files but omitted the new native marketplace source parser, so its 96.8504% was not a current whole-scope certificate. Added malformed listing/source, mixed-row rejection, and actionable diagnostic witnesses through the public source reader; its 31 integration tests pass. The first fresh complete Codex run passed at 94.1176% on 459 mutants (430 killed, two timed out, 27 survived), covering all seven matching source files. A final run including four added diagnostic cases is under verification, with the 94% floor unchanged. +- Codex consolidation: the diagnostic cases reduce survivors to 21. A high-concurrency run overlapping the full suite reported 11 timeouts; it is not used as evidence of stability. A subsequent complete-scope run at concurrency two passed at 95.4248366% in 4m02s, with zero timeouts and the 94% floor unchanged. +- CI on `3946be7f` confirms Smoke passes, but Windows reports 37 failures because resolving every virtual `realpath` identity changed seeded project-reference identities. Two Windows-model tests reproduce the regression. The correction resolves symlink lookup keys while preserving unlinked paths and declared target identities; no production behavior or refusal assertion is relaxed. Final-head Windows verification remains pending. +- CI on `95497fee` confirms the external-symlink refusals and project-reference identity cases pass. Three Windows failures remain in assertions introduced by the mutation hardening: two cache-warning expectations hardcode POSIX separators, and a cache-neighbor list expectation ignores the double's slash-normalized keys. Expected diagnostics now use platform `join`; the neighbor-list expectation uses the documented normalization while retaining exact path and byte-preservation assertions. Final-head CI remains pending. +- Runtime bottleneck: in the final run Stryker estimated that 91 static mutants (4% of mutants scheduled for replay) would account for 61% of execution time. None were ignored. Harness optimization must preserve their witness coverage and sound cache invalidation. +- Marketplace-removal additions cover reserved shared-catalogue refusal before registry access, exact user-catalogue selection among competing entries, exact native scope and host name, implicit host ref scope, a proven empty catalogue without plugin claims, missing canonical ledger, and project cleanup without a native mapping or orphan. The two targeted campaigns took 17s and 21s; the final report contains 202 killed / 10 survived across 212 mutants. The surviving empty file-scope literal still resolves to the same user directory; no invalid runtime scope was introduced just to distinguish it. +- Synchronization additions in `cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts` and `marketplace-sync-source-provenance.integration.test.ts` cover implicit/explicit project synchronization inside the machine lock, user-scope non-reentrancy, lock failure before project reads or host writes, missing/unreadable plugin registries with and without a diagnostic, explicitly absent plugin registries followed by post-add source proof, and Claude catalogue absence versus late unreadability or a newly appearing same-name foreign catalogue. The two targeted campaigns took 50s and 53s; the latest report contains 96 killed / 16 survived / 0 uncovered across 112 mutants. Their overlapping gains were counted once. +- Late-refusal/projection additions in `cli/tests/contexts/framework/application/flows/marketplace-sync-native-provenance.integration.test.ts` cover a transient source-read refusal followed by recovered proof, foreign/missing source, foreign refs or unreadable refs, for Codex and the future-supported Copilot test double. Existing project references and B's machine claims survive invalid proof; recovered proof attaches only this project. Narrowed sync drops obsolete target references only after valid proof and retains references when another alias still owns the same host catalogue. The Claude late-collision test also explicitly asserts an empty project projection. `829-sync-late-refusal-projections/mutation.json` matches current source: 130 killed / 17 survived / 1 uncovered across 148 mutants, in 44s; 19 detections beyond the complete snapshot, of which two overlap the preceding registry-proof lot, so the net addition is 17. No claim of real Copilot support follows from these doubles. +- Catalogue-versus-plugin additions in `cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts` cover project/user Claude catalogue refusal without phantom machine claims, a catalogue containing an undeclared plugin without enabling or claiming that plugin, scope-specific machine catalogue ownership for Claude/Codex, idempotent ownership saves, preservation of B's existing claims and tool version, another alias pointing at the same host, and fresh machine tool/version initialization. `829-sync-catalogue-plugin-claims/mutation.json` matches current source: 109 killed / 13 survived / 0 uncovered across 122 mutants, in 41s; 14 detections beyond the complete snapshot, of which five overlap preceding lots, so the net addition is nine. +- The first threshold-93 batch, `829-threshold93-native-recovery/mutation.json`, measured 152 novel detections beyond every preceding targeted lot: clean 39, sync 32, plugin removal 27, hooks 28, plugin add 7, init 7, native registration gate 7, file updater 5. It completed in 2m58s across 1,034 mutants. Fourteen formerly detected mutations survived in this narrower run; these discrepancies are retained for the whole-framework replay, not omitted from the verdict. Added witnesses assert preflight state preservation, native cache retention on binary loss, exact host diagnostics, recovery races and scope, public upgrade/no-op behavior, and hook contribution ownership. The test skill's behavioral contract ruled out contrived invalid inputs for unreachable branches. +- Separate diagnostic finding: `CleanUserScopeUseCase` checks user files with its injected home directory, but `deletePluginFilesForTool` resolves deletion through OS `nodeHomedir` (`cli/src/contexts/framework/application/plugin/plugin-helpers.ts:58`). The truthful OS-home test proves normal user-file deletion; it does not certify custom injected-home deletion. No production workaround was added to raise the mutation score. +- The final targeted batch, `829-threshold93-final-contracts/mutation.json`, added another 61 novel detections: user clean 21, sync 17, native source proof 5, cache purge 6, built materialization 3, runtime settings 3, plugin removal 2, tool uninstall 4. It completed in 1m45s across 508 mutants. All narrow-run discrepancies were left for the complete replay; the reported final global score, not the sum or average of targeted scores, is the certified result. +- Threshold-93 objective complete. Remaining delivery work: verification of the other thirteen scope gates and the existing Copilot follow-up; custom-home cleanup is separately documented. No legacy migration, commit, or push was performed. diff --git a/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/backlog-link.json b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/backlog-link.json new file mode 100644 index 000000000..75e33474d --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/backlog-link.json @@ -0,0 +1,5 @@ +{ + "backlog": "ai-driven-dev/framework#250", + "written_at": "2026-09-18T09:24:15Z", + "written_by": "aidd-pm:04-spec" +} diff --git a/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/phase-1.md new file mode 100644 index 000000000..61e8d98f7 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/phase-1.md @@ -0,0 +1,106 @@ + +# Instruction: The two rules, as a tested engine + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── scripts +│ ├── lib +│ │ └── architecture-rules.js ✅ the two rules, as pure functions +│ └── __tests__ +│ ├── architecture-rules.test.js ✅ both rules, both directions, plus the clean-tree sweep +│ └── fixtures +│ └── architecture-rules ✅ synthetic plugin trees, written for this task +└── docs + └── ARCHITECTURE.md ✏️ one line pointing at the guard that now enforces the rule +``` + +## User Journey + +```mermaid +flowchart TD + A[A contributor edits a plugin source] --> B{Does the prospective content break a rule?} + B -- no --> C[Nothing is said] + B -- "a sibling plugin is addressed" --> D[File, line, owning plugin] + B -- "the Actions section is out of step" --> E[File, line, the action that does not match] + D --> F[The contributor corrects it and edits again] + E --> F +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Write the synthetic plugin fixtures under the test fixtures directory => fixtures on disk: 5: system + section Happy path + Run the rule engine over a fixture whose skill addresses only its own plugin => no violation: 5: cli + Run the rule engine over the repository's own plugins directory => no violation: 5: cli + section Edge case - a recipe skill addresses a sibling + A skill file names another plugin's skill => run the engine over it => one violation naming the file, the line and the owning plugin: 1: cli + section Edge case - a permission list addresses a sibling + An agent lists a sibling skill under its permission heading => run the engine over it => no violation: 1: cli + section Edge case - an orchestration reference addresses a sibling + An orchestrator reference names a provider => run the engine over it => no violation: 1: cli + section Edge case - an action file no section names + A skill gains an action file its Actions section never mentions => run the engine over it => one violation naming the file: 1: cli + section Edge case - a section whose mention is only prose + A word in the section's prose matches an action's stem but no row cites it => run the engine over it => one violation naming the file: 1: cli +``` + +## Tasks to do + +### `1)` The failing tests come first + +> Every rule gets its red before it gets its engine. + +1. Write the synthetic fixtures: a plugin tree with a clean skill, a skill addressing a sibling, an agent permission list addressing a sibling, an orchestrator reference addressing a sibling, a skill with an unnamed action file, a skill citing an absent action. +2. Write `architecture-rules.test.js` covering each fixture and each expected verdict, plus one case that sweeps the repository's real `plugins/` and expects zero violations. +3. Run the suite and watch every case fail for the absence of the module, not for a typo. + +### `2)` The orthogonality rule + +> A dispatch surface never names a sibling plugin. + +1. Expose a function taking a repository-relative path and the prospective content, returning violations with a line, a 1-indexed number, the address found and the plugin that owns it. +2. Match `/:` and `@:` addresses; a match whose plugin equals the file's own owner is not a violation. +3. Govern only `SKILL.md`, `actions/*.md`, `references/*.md` and `agents/*.md`. Anything under `assets/` returns nothing. +4. Exempt a file owned by an orchestrator plugin, and exempt the lines under an agent's `# Skills you may invoke` heading. + +### `3)` The router coherence rule + +> An Actions section cites every action that exists. + +1. Take the prospective `SKILL.md` content and the names of the skill's action files. +2. Isolate the `## Actions` section, up to the next second-level heading. +3. Report an action file the section cites by neither its stem, its file name, nor an `actions/.md` path. Collect a citation from the table's action column, never from running prose. +4. Report the line of the section heading. A citation with no file behind it is deliberately not reported — it cannot be told apart from one written just before the file it names. + +### `4)` The rule the repository already follows + +> A guard that is red on a clean tree is a guard nobody keeps. + +1. Run the sweep case over the real `plugins/` tree. +2. When a rule fires there, the rule is wrong, not the tree. Narrow it and record why in `plan.md`'s decisions. + +### `5)` The rule document points at its enforcement + +> `docs/ARCHITECTURE.md` states the rule; say where it is now checked. + +1. Add one sentence naming the guard, plugin-relative and in backticks, never as a link. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | Every case in the suite fails before the engine exists, each for the missing module | +| 2 | A skill addressing a sibling yields a violation naming file, line and owning plugin; an agent permission list and an orchestrator reference yield none; a file under `assets/` yields none | +| 3 | An action file the section never cites yields one violation; a stem appearing only in prose does not count as a citation; a skill whose section and files agree yields none | +| 4 | Sweeping the repository's own `plugins/` yields zero violations | +| 5 | `docs/ARCHITECTURE.md` names the guard, and the markdown-link check still passes | diff --git a/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/phase-2.md new file mode 100644 index 000000000..27c71e1f2 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/phase-2.md @@ -0,0 +1,103 @@ + +# Instruction: The refusal, at the AI host's write moment + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── .claude +│ ├── hooks +│ │ └── check-architecture-rules.js ✅ reads the pending edit, calls the engine, refuses +│ └── settings.json ✏️ one PreToolUse entry matching Write and Edit +└── scripts + └── __tests__ + └── architecture-hook.test.js ✅ the payload shapes and the refusal contract +``` + +## User Journey + +```mermaid +flowchart TD + A[An agent calls Write or Edit on a plugin source] --> B[PreToolUse hands the hook the pending content] + B --> C{Does the prospective content break a rule?} + C -- no --> D[The write proceeds] + C -- yes --> E[The call is denied, with file, line and owning plugin] + E --> F[The agent corrects the content and calls again] + F --> B +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Build a PreToolUse payload for a plugin file => payload on stdin: 5: system + section Happy path + Run the hook on a payload whose content breaks no rule => exit zero and no output: 5: cli + section Edge case - a Write that introduces a sibling address + The payload carries content addressing another plugin => run the hook => a deny decision naming file, line and owning plugin: 1: cli + section Edge case - an Edit that introduces a sibling address + The payload carries an old and a new string => run the hook => the reconstructed content is judged, and the call is denied: 1: cli + section Edge case - an Edit whose old string is absent + The payload cannot be reconstructed => run the hook => exit zero, because the edit will fail on its own: 1: cli + section Edge case - a file outside the governed surface + The payload names a file under assets => run the hook => exit zero: 1: cli + section Teardown + Remove the temporary payload files => baseline restored: 5: system +``` + +## Tasks to do + +### `1)` The hook, generated by the capability that owns hooks + +> The decider asked for a host-native lifecycle hook produced by the hook capability, not a hand-placed script. + +1. Run `/aidd-context:08-hook-generate` for a `PreToolUse` hook matching `Write|Edit`, at the project scope, for Claude Code — the one host this repository configures hooks for, per the plan's decisions. +2. Keep the generated entry and script; give the script the name the projection states. + +### `2)` The prospective content + +> Judge what the file will hold, not what it holds now. + +1. Read the payload from standard input. Take `tool_input.file_path`. +2. For a `Write`, the prospective content is `tool_input.content`. +3. For an `Edit`, read the file and apply `old_string` to `new_string`, once or everywhere per `replace_all`. +4. When the file path is outside the governed surface, or the reconstruction fails, exit zero and say nothing. + +### `3)` The refusal + +> A refusal a reader cannot act on is noise. + +1. Call the engine with the path, the prospective content, and the skill's action file names when the path is a `SKILL.md`. +2. On a violation, emit the `PreToolUse` deny decision, with a reason naming each file, line, and the plugin that owns the address, and what to write instead. +3. On no violation, exit zero with no output. + +### `4)` The failing tests come first + +> The hook is a contract with the host; test it as one. + +1. Write `architecture-hook.test.js` driving the hook as a subprocess with each payload shape. +2. Watch each case fail before the hook exists. +3. Assert the decision is a deny and the reason carries the file and the line, never only the rule. + +### `5)` The proof + +> A guard nobody watched fire is a comment. + +1. Write a plugin file addressing a sibling, through the agent's own Write tool, and record that the call was refused and what it said. +2. Write an agent permission list naming a sibling the same way, and record that it was applied. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | The hook entry exists in `.claude/settings.json` at the project scope and fires on `Write` and `Edit` | +| 2 | A `Write` payload and an `Edit` payload over the same file yield the same verdict for the same resulting content | +| 3 | A denied call returns a reason naming file, line and owning plugin; a clean call returns nothing at all | +| 4 | Every case fails before the hook exists, then passes | +| 5 | An attempted write of a sibling address is refused in the same turn; an attempted write of a permission list is applied | diff --git a/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/plan.md b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/plan.md new file mode 100644 index 000000000..03e781163 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/plan.md @@ -0,0 +1,48 @@ + +# Plan: Architecture guard on AI-authored edits + +## Overview + +| Field | Value | +| --- | --- | +| **Goal** | Refuse an AI edit that would hardcode a sibling plugin's address, or leave a skill's `## Actions` section out of step with its action files, before the edit lands. | +| **Source** | [`spec.md`](./spec.md), from [ai-driven-dev/framework#250](https://github.com/ai-driven-dev/framework/issues/250) | + +## Phases + +| # | Phase | File | +| --- | --- | --- | +| 1 | The two rules, as a tested engine | [`phase-1.md`](./phase-1.md) | +| 2 | The refusal, at the AI host's write moment | [`phase-2.md`](./phase-2.md) | + +## Resources + +| Source | Verified | +| --- | --- | +| `docs/ARCHITECTURE.md`, capability addressing | A capability is addressed only where dispatch is declared: a router's `## Actions` table, an agent's `# Skills you may invoke` list. Agent permission lists and orchestration references legitimately name a provider; recipe skills never do. | +| | `PreToolUse` receives `tool_input` before the tool runs — `content` for `Write` — and refuses the call with `hookSpecificOutput.permissionDecision: "deny"` plus a `permissionDecisionReason` the model reads. `PostToolUse` cannot refuse, it only reports after the fact. | +| `.claude/hooks/check-written-file.js` | The in-repo precedent for a hook that reads the payload from stdin, resolves the written file, and hands a report back in the same turn. | +| Issue #250, comment of 2026-09-14 | Supersedes the issue body's "Guardrail local et CI": no Git hook, no CI gate, synthetic fixtures, #406 neither blocker nor fixture. | +| Probe over the 51 skills and 8 plugins in the tree | Both rules, as scoped below, report zero violations on the repository as it stands — orthogonality across 37 real cross-plugin addresses, router coherence across 48 skills that hold action files. The probe measured both directions; only the decidable one ships, per the decision below. | + +## Decisions + +| Decision | Why | +| --- | --- | +| `PreToolUse`, not `PostToolUse` | The decision is "prevent the write until it is corrected". `PostToolUse` fires after the file is already on disk; Biome gets away with it only because it rewrites in place, and this guard cannot rewrite prose. | +| The engine is a plain module, the hook is a thin caller | A rule that is a pure function of (path, prospective content, action-file listing) is testable without a hook, a host, or a tree. The hook contributes only the payload and the refusal. | +| The governed surface is `SKILL.md`, `actions/`, `references/`, `agents/` | That is where dispatch is declared. `assets/` hold sheets a reader reads — `12-cook`'s recipes name 20 cross-plugin commands on purpose — so they are excluded by a stated rule, never by a quiet path filter. | +| An orchestrator plugin is exempt from rule one, and only rule one | `docs/ARCHITECTURE.md` makes orchestration references responsibility maps. `aidd-orchestrator` holds 14 of the tree's cross-plugin addresses for exactly that reason. Router coherence is a different rule and applies to it like any other plugin. | +| The router rule reads the `## Actions` section, not the table | `10-todo` names its one action as a path in a fenced block rather than a table row. Scoping to the section and matching either the stem or the file name covers both shapes and still reports zero on the tree. | +| Unit tests live under `scripts/__tests__/`, and that is not a CI gate on the rules | Pre-commit runs those tests against synthetic fixtures, proving the engine works. Nothing scans the tree at commit time or in CI, which is what the decider ruled out. | +| Fixtures are written for this task | The spec forbids #406's historical code. Each rule gets a breaking fixture and a legitimate-naming fixture, so a guard that flags a permission list fails its own suite. | +| The hook is wired for Claude Code alone | It is the only host this repository configures hooks for: `.codex/config.toml` carries a sandbox mode and nothing else. Codex, Cursor and Copilot all expose `PreToolUse` and the same deny shape, but Codex delivers a file edit as an `apply_patch` command string rather than a path and a content, which is a different parse. The engine is host-agnostic, so each adapter is additive and none of them touches a rule. | +| Rule two enforces one direction, not two | A citation with no file behind it cannot be told apart from a citation written just before the file it names. Enforcing it deadlocked adding an action: creating the file first was refused for not being named, naming it first was refused for having no file, and `aidd-context:04-skill-generate` documents the second order. The decidable direction is kept. | +| An action is named by a citation, never by a word | Plain containment over the section let ordinary prose pass for a mention: `plan` appears in "the plan is the culmination", so deleting that action's row went unnoticed. Measured over the tree, containment missed 20 of 78 row deletions; citation matching misses none that has a row. | +| One plugin source was repaired, and it is the exception | `plugins/aidd-dev/skills/01-plan/actions/04-plan.md:16` read "declare it the same way `aidd-pm:04-spec` does", which is rule one's own violation in prose. The spec's non-goal keeps existing violations out of scope, and the hard constraint says a rule red on the tree is miscalibrated — so the one line that was genuinely wrong was fixed, and the seven in `00-onboard` were exempted with #883 behind them instead. No other plugin source is touched. | +| A stem speaks for its action only when no sibling shares it | `01-plan.md` and `04-plan.md` both reduce to `plan`, so one row would cover both and deleting either would go unnoticed. A shared stem cites nobody; the numbered name still does. No skill in the tree has a collision today, which is why it had to be reasoned about rather than observed. | +| A fenced block is an example, never a router | The section scan blanks fences before reading headings and tables, so a documented `## Actions`, a `##` inside the section, and an example table all stop steering the verdict. Path citations are read from the unblanked lines, because `10-todo` legitimately cites `actions/01-todo.md` inside a fence. | +| Only the path shape reads through a fence | Both shapes read the unblanked lines at first, which let a backticked `.md` inside a fenced example cite — the very hole blanking fences was for. `10-todo` needs the path shape and nothing needs the other, so the exception is one shape wide. | +| A table resumed after a blank line keeps its column | A run of rows with no `| --- |` beneath it is read as the table above it, resumed. Otherwise inserting one blank line — changing no content — refuses every row below it. The cost: a separator-less glossary written just under the router donates its cells, so a deleted row could hide behind it. Neither shape is a table a renderer accepts, and the false refusal is the worse failure. A column-count check does not separate them: a glossary often has the router's width. | +| A separator row counts from two dashes | `aidd-context:00-onboard` writes `| -- |` and GitHub renders it. Requiring three silently stopped the header being recognised, which unnamed every action in that skill. The real-tree sweep caught it. | +| A fence nobody closed is not a fence | Blanking from an unterminated fence to end of file hides a `## Actions` that is visibly there, and the refusal then reads "has action files but no ## Actions section" — unactionable. The whole document is read as unfenced instead. | diff --git a/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/spec.md b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/spec.md new file mode 100644 index 000000000..9addb4bbf --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/spec.md @@ -0,0 +1,58 @@ +# Architecture guard on AI-authored edits + +## Target + +An AI-authored edit that would break one of this repository's two named architecture rules is refused before it lands, with the offending file and line named. + +## Hard constraints + +- The guard decides before the edit is applied and refuses it. It is not a commit-time gate and not a continuous-integration job. +- The guard is deterministic: the same prospective file content yields the same verdict, with no dependence on model judgement and none on which AI tool made the edit. +- The guard is produced by the project's own hook-generation capability, so it exists for the AI hosts this repository configures rather than for one. +- Rule one, cross-plugin orthogonality: a plugin's dispatch surface must not name a sibling plugin by a hardcoded address. +- Rule two, router coherence: a skill's `## Actions` section must cite every action file that skill provides. A citation is a table cell, a fenced `actions/.md` path, or a backticked file name — never a bare word in running prose. +- The governed surface is the dispatch surface: a skill's `SKILL.md`, its actions and its references, and an agent's definition. A plugin's `assets/` hold content shown to a reader, not dispatch, and are out of the guard's reach by this rule rather than by an unstated path filter. +- Naming that `docs/ARCHITECTURE.md` declares legitimate stays silent: an agent's permission list and an orchestration reference are responsibility maps and name their provider canonically. Flagging either is a defect of the guard, not of the tree. +- Every refusal names the file, the line, and the plugin that owns the addressed capability, in terms a reader can act on without opening the rule document. +- The guard is silent on the repository as it stands. A fenced block holds an example, never a router: a `## Actions` inside one is not the section, a `##` inside one does not end it, and a table inside one cites nothing. A fenced `actions/.md` path is the one exception, because `aidd-dev:10-todo` cites its only action that way — so a fenced example writing that shape does cite, and can mask a row deleted elsewhere. Two more shapes are read generously and stay that way: a run of table rows with no separator under it is taken for the table above it resumed after a blank line, so a separator-less glossary written just below the router would donate its cells; and rule one reads addresses through fences, so a governed file teaching a counter-example is refused. Neither shape is a table a renderer accepts, and a refusal nobody can act on is the worse failure. A rule that reports a violation in the current tree is miscalibrated, not vindicated. +- The rules are exercised by purpose-built fixtures. No historical code from #406 is used as a fixture. +- Each rule is proved by a fixture that breaks it and turns exactly the test named for that rule red, and by a fixture of legitimate naming that stays green. + +## Non-goals + +- A `lefthook` pre-commit gate for these rules. The decider ruled it out on 2026-09-14. +- A CI job enforcing these rules. +- Judging whether a skill's prose `description` has gone stale. Nothing mechanically separates stale prose from current prose, so the issue's "router/description mismatch" is served by the router half alone. +- Refusing a citation with no action file behind it. A citation written seconds before the file it names is indistinguishable from a stale one, and this project's own skill generator writes the router first, so enforcing that direction makes adding an action impossible in either order. The decidable direction — an action file the section never cites — is the one the issue names, and the one enforced. +- Catching an action file created and never cited. Rule two decides on a write to a `SKILL.md`; an orphan action file is caught at the next write to its skill's router, not at its own creation. Firing on the action file is what broke the order that creates the file first, and dropping it was the cheaper half of breaking the deadlock. The window is unbounded: that next write may never come, so this is a hole, not a delay. +- Reaching into a plugin's `README.md`, which addresses a sibling in two places today. Like `assets/`, it is a document a reader reads, not a dispatch the skill executes. +- Reaching into a plugin's `assets/`. A recipe sheet names the commands a reader types; that is its subject, not a dispatch this rule governs. +- Repairing violations that exist in the tree today. That was #406, now closed. One line is the stated exception, named in the plan's decisions: a prose sentence in `aidd-dev`'s planning action addressed a sibling skill outright, and leaving it would have made the guard red on its own tree. +- Enforcing any architecture rule beyond the two named above. +- Catching a violation introduced outside an AI tool, by a human editing by hand. +- Catching a write performed through a shell command rather than a write tool. Deciding whether a shell line writes a plugin source means parsing arbitrary shell, which is not the deterministic verdict rule one requires. An agent told to edit through `sed` or a heredoc is therefore ungoverned, and that is stated here rather than left to be discovered. +- Making `aidd-context:00-onboard` resolve its providers at runtime. Its reference menus name addresses because those addresses are what it hands a person to type, so it is exempt by a named rule with a follow-up issue behind it, not by silence. +- Shipping the guard into projects that install this marketplace. The rules govern this repository's own plugin sources. + +## Done-when + +- An edit that would leave a skill's dispatch surface holding a sibling plugin's hardcoded address is refused, and the refusal names that file, its line, and the plugin that owns the address. +- An edit that would leave a skill's `## Actions` section not naming an action file the skill provides is refused, and the refusal names the file and the line. The section names an action by citing it, so a word in prose that happens to match a stem never passes for a mention. +- An edit that writes an agent permission list, or an orchestration reference, naming its provider canonically is applied with no complaint. +- Breaking each rule in its fixture turns red exactly the test named for that rule, and no other test. +- The refusal reaches the author in the same turn as the edit that caused it, before any commit, push, or CI run. +- Every plugin source in the repository as it stands passes both rules. +- A contributor who has never read `docs/ARCHITECTURE.md` can correct a refused edit from the refusal text alone. + +## Stakeholders + +- Decider: the repository owner, who ruled out the commit-time and CI mechanisms on 2026-09-14. +- Owner: the framework maintainers. +- Consumer: every contributor and every agent that edits a plugin source in this repository. + +## Context + +- Backlog item: [ai-driven-dev/framework#250](https://github.com/ai-driven-dev/framework/issues/250). +- The `00-onboard` exemption has an expiry, not a pass: [ai-driven-dev/framework#883](https://github.com/ai-driven-dev/framework/issues/883) makes that skill resolve its providers at runtime, and closing it closes the exemption. +- The issue body's "Guardrail local et CI" section predates the 2026-09-14 comment on the same issue and is superseded by it. The comment is the governing statement: no Git hook, no CI gate, synthetic fixtures, #406 neither blocker nor fixture. +- The orthogonality rule and its legitimate exceptions are stated in `docs/ARCHITECTURE.md`, under capability addressing: a capability is addressed only where the dispatch is declared, and elsewhere the concept is named instead of the skill that owns it. diff --git a/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/wiring-proof.md b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/wiring-proof.md new file mode 100644 index 000000000..7d81908d9 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/wiring-proof.md @@ -0,0 +1,53 @@ +# Wiring proof + +Phase 2's task 5 asks for a recorded live refusal, because everything else about this guard +is verified upstream of the seam that matters: the tests spawn the hook script and pipe it a +payload, which proves the script and never proves the wire. The matcher string, the +`$CLAUDE_PROJECT_DIR` expansion inside the command, and `node` being on `PATH` are only +exercised by a real tool call — and the hook fails open at every one of those steps, so a +broken wire looks exactly like a clean tree. + +Both calls below were made with the Write tool, in this repository, against +`.claude/settings.json` as committed. + +## A refusal + +Writing `plugins/aidd-dev/skills/03-assert/references/wiring-proof.md`, a reference file in a +recipe skill, with this body: + +```md +# Wiring proof + +Hand the result to `/aidd-vcs:01-commit` once every assertion passes. +``` + +The call was refused. Verbatim, as it reached the author: + +```text +plugins/aidd-dev/skills/03-assert/references/wiring-proof.md:3 addresses sibling plugin "aidd-vcs" via "/aidd-vcs:01-commit". Fix: name the concept aidd-vcs owns instead of addressing it directly. +``` + +The file was never created — `ls` on that path answers `No such file or directory`. + +## The same address, legitimately + +Writing `plugins/aidd-dev/agents/wiring-proof.md` with the same address under the heading +`docs/ARCHITECTURE.md` sanctions: + +```md +# Skills you may invoke + +- `/aidd-vcs:01-commit` +``` + +The call was applied, with no output. The probe was removed in the same turn; +`git status --porcelain plugins/` is empty. + +## What this settles + +| Claim | Settled by | +| --- | --- | +| The hook is reachable from the matcher as wired | the refusal arrived at all | +| It decides before the write, not after | the refused path does not exist | +| The refusal names file, line and owning plugin | the verbatim text above | +| A legitimate permission list is not refused | the second call was applied silently | diff --git a/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/backlog-link.json b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/backlog-link.json new file mode 100644 index 000000000..f0dfabec7 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/backlog-link.json @@ -0,0 +1,5 @@ +{ + "backlog": "ai-driven-dev/framework#911", + "written_at": "2026-09-23T00:00:00Z", + "written_by": "aidd-dev:01-plan" +} diff --git a/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/brainstorm.md b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/brainstorm.md new file mode 100644 index 000000000..32e653852 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/brainstorm.md @@ -0,0 +1,28 @@ +# Credit contributors in release notes + +Refines [#911](https://github.com/ai-driven-dev/framework/issues/911). + +Every line of the generated release notes ends with the GitHub `@handle` of the commit's author, Symfony-style, for the root release and for each plugin and cli release. The point is to make everyone who ships visible, release after release, maintainer included. Attribution was meant to rely on release-please's native `include-commit-authors` option; a sandbox run proved it a no-op (upstream googleapis/release-please#2761), so a step post-processes each GitHub release instead. `main` accepts only merge commits, so a promote always keeps every commit and its author. + +## What Is Clear + +- Per-line credit only. No contributor section at the end of a release, no deduplication. +- Everyone is credited, maintainer included. +- A plugin release credits only the authors of that plugin's commits, because release-please builds each component's notes from its own commits. +- `include-commit-authors` exists but credits nobody in 17.11.2: `parseConventionalCommits` drops the author. Fix pending upstream in #2892; the post-processing step is removed once it ships. +- `changelog-type: github` is rejected: it reads the whole range between tags, which is wrong for component releases. +- `(@dependabot[bot])` on dependency lines is accepted. +- Verified on the last 60 PRs merged into `next`: the squash commit's author is the PR author (55/55 humans), even when someone else merges. Every commit email resolves to a GitHub account. +- Promote to `main` is a merge commit today (`promote.yml`, #809), which keeps each commit and its author. It stays as is. +- Guard: `main` allows only merge commits. A squashed promote would silently drop the week's entries (proven in the sandbox). The release PR moves to `--merge --admin`, and the live ruleset changes only after that has released once. +- Only the GitHub releases carry the handles; `CHANGELOG.md` and the release PR body do not. Accepted. +- A multi-author PR credits its author only; `Co-authored-by:` trailers are ignored. Accepted. +- Out of scope: other forms of contribution (issues, reviews, discussions), and a weekly shout-out on overall contribution, which gets its own issue. + +## Still Open + +- None. Settled in planning; see `plan.md`. + +## Next Move + +Implement `plan.md`. diff --git a/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/phase-1.md new file mode 100644 index 000000000..4a226d025 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/phase-1.md @@ -0,0 +1,89 @@ +--- +status: pending +--- + +# Instruction: Credit commit authors in GitHub releases + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── scripts/credit-release-authors.cjs ✅ pure line transform plus a thin gh runner +├── scripts/__tests__/credit-release-authors.test.js ✅ transform fixtures and ci.yml wiring +├── .github/workflows/ci.yml ✏️ credit step after the release is created +└── aidd_docs/memory/deployment.md ✏️ Release: releases credit authors, and why it is a step +``` + +## User Journey + +```mermaid +flowchart TD + A[Release PR merged] --> B[release-please creates up to nine releases] + B --> C[Credit step reads each created tag's release body] + C --> D[Each line's commit SHA resolved to its author] + D --> E["Line gains (@login), or the name when no account"] + E --> F[Release body updated once; a re-run changes nothing] +``` + +## Test Scope + +```mermaid +flowchart LR + T[transform fixtures] --> T1[two contributors, each credited] + T --> T2[a repeated contributor, credited on each line] + T --> T3["a bot, credited as @dependabot[bot]"] + T --> T4[a line ending in closes #N, credited after it] + T --> T5[a line without a commit SHA, untouched] + T --> T6[a line already credited, untouched] + T --> T7[no login, name without @] + W[ci.yml wiring] --> W1[the step runs only when releases were created] + W --> W2[the step runs after the release step, with the App token] +``` + +## Tasks to do + +### `1)` Write the failing transform tests + +> Pin the line format on real release lines before any code. + +1. Create `scripts/__tests__/credit-release-authors.test.js` with T1 to T7, fixtures copied from `v5.10.0`'s real lines. +2. The transform takes a body and a `sha → display` resolver, so no fixture touches the network. +3. Run it and watch it fail on the missing module. + +### `2)` Write the transform and its runner + +> Pure function first, the runner only fetches and writes. + +1. Create `scripts/credit-release-authors.cjs` exporting `credit(body, resolve)`. +2. A header comment links googleapis/release-please#2761 and #2892 and says to delete the script once a fixed release-please is pinned. +3. The runner takes the created tags, reads each body with `gh release view`, resolves each SHA once with `gh api repos//commits/`, and writes back with `gh release edit --notes-file -` only when the body changed. +4. An API failure exits non-zero and names the tag, never writes a partial body. +5. Tests green. Mutation: drop the already-credited guard and watch T6 go red. + +### `3)` Wire the step + +> After the releases exist, on the run that created them. + +1. Read how `release-please-action` v5.0.0 exposes each created tag (root `tag_name`, per path `--tag_name`) from its `action.yml` or source before relying on it. +2. Add a step to the `release-please` job, after "Pin umbrella release as latest", guarded on `releases_created`, passing the action's outputs to the runner and the App token as `GH_TOKEN`. +3. Extend the test with W1 and W2, loading `ci.yml` with js-yaml. +4. Mutation: drop the guard and watch W1 go red. + +### `4)` Document + +> Once, where the release is described. + +1. In `aidd_docs/memory/deployment.md`'s Release section, state that a step credits each release line's commit author, as a workaround for the upstream bug. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | The tests fail first for a missing module | +| 2 | T1 to T7 pass. Removing the already-credited guard turns T6 red | +| 3 | W1 and W2 pass. Removing the `releases_created` guard turns W1 red | +| 4 | `pnpm exec lefthook run pre-commit` is green | +| all | `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` is green | +| live | the first release after merge shows `(@login)` on every line | diff --git a/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/phase-2.md new file mode 100644 index 000000000..029da516e --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/phase-2.md @@ -0,0 +1,90 @@ +--- +status: pending +--- + +# Instruction: Restrict main to merge commits + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── .github/workflows/ci.yml ✏️ release PR merged with --merge --admin +├── .github/rulesets/main.json ✏️ pull_request rule: allowed_merge_methods ["merge"] +├── scripts/__tests__/main-merges-by-merge-commit.test.js ✅ merge-method assertions +├── aidd_docs/memory/deployment.md ✏️ Release step 1: merge commit, not squash +└── RELEASE.md ✏️ Hotfix lands as a merge commit +``` + +## User Journey + +```mermaid +flowchart TD + A[Someone opens the merge menu on a PR to main] --> B{Method} + B -->|merge commit| C[Merged, every commit and author kept] + B -->|squash or rebase| D[Refused by the ruleset] + E[release-please job] -->|gh pr merge --merge --admin| C + F[promote.yml] -->|gh pr merge --merge --auto| C +``` + +## Test Scope + +```mermaid +flowchart LR + T[structural test] --> T1[main.json allows only merge] + T --> T2[ci.yml merges the release PR with --merge] + T --> T3[no workflow merges into main with --squash or --rebase] + L[live check after rollout] --> L1[next release PR merges and tags] + L --> L2[back-merge folds main into next] +``` + +## Tasks to do + +### `1)` Write the failing assertions + +> Pin the merge method in the files before changing them. + +1. Create `scripts/__tests__/main-merges-by-merge-commit.test.js` asserting T1 to T3, loading the files as `release-covers-every-plugin.test.js` does. +2. Watch T1 and T2 fail on the current files. + +### `2)` Switch the release PR to a merge commit + +> The one bot merge into `main` that squashes today. + +1. In `ci.yml`, replace `--squash --admin` with `--merge --admin` on the release PR merge, and update its comment. +2. T2 and T3 green. Mutation: put `--squash` back and watch T3 go red. + +### `3)` Declare the ruleset change + +> The file, not the live ruleset. + +1. In `.github/rulesets/main.json`, add `"allowed_merge_methods": ["merge"]` to the `pull_request` rule's parameters. +2. T1 green. + +### `4)` Document + +> Where the release and the hotfix are described. + +1. `deployment.md` Release step 1: the release PR merges as a merge commit; `main` allows no other method. +2. `RELEASE.md` Hotfix: the PR lands as a merge commit, so each of its commits must be conventional. + +### `5)` Roll out, in this order, with the maintainer + +> The live ruleset changes last, only after the new merge has proven itself. Applied first, it refuses the `--squash --admin` release merge: proven in the sandbox. + +1. Ship phases 1 and 2 through `next` and a promote. The ruleset file changes; the live ruleset does not yet. +2. Wait for that cycle's release PR to merge with `--merge` and for release-please to tag it. +3. Check back-merge folded `main` into `next`. +4. Only then, with the maintainer's explicit go: `gh api -X PUT repos/ai-driven-dev/framework/rulesets/12902947 --input .github/rulesets/main.json`. +5. Read back `allowed_merge_methods` from the live ruleset. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | T1 and T2 fail on the current files for the reason they name | +| 2 | T2 and T3 pass. Restoring `--squash` turns T3 red | +| 3 | T1 passes, and `.github/rulesets/main.json` stays valid JSON | +| 4 | `pnpm exec lefthook run pre-commit` is green | +| 5 | A release PR merged with `--merge` is tagged, back-merge succeeds, and the live ruleset reads `["merge"]` | diff --git a/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/plan.md b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/plan.md new file mode 100644 index 000000000..1a25d3d60 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/plan.md @@ -0,0 +1,60 @@ +--- +objective: "Every GitHub release credits each changelog line's commit author by @handle, and main accepts only merge commits so a promote keeps every commit and its author." +status: pending +--- + +# Plan: Credit contributors in release notes + +## Overview + +| Field | Value | +| --- | --- | +| **Goal** | Post-process each GitHub release to append the commit author's `@handle` per line, and restrict `main` to merge commits | +| **Source** | [#911](https://github.com/ai-driven-dev/framework/issues/911), [`brainstorm.md`](./brainstorm.md) | + +## Phases + +| # | Phase | File | +| --- | --- | --- | +| 1 | Credit commit authors in GitHub releases | [`phase-1.md`](./phase-1.md) | +| 2 | Restrict main to merge commits | [`phase-2.md`](./phase-2.md) | + +## Resources + +| Source | Verified | +| --- | --- | +| Sandbox repo, release-please 17.11.2 CLI, `include-commit-authors: true` | the generated notes carried no author at all | +| `release-please` 17.11.2 `build/src/commit.js` `parseConventionalCommits` | the parsed commit drops `author`, so `default.js` never sees one. Upstream [#2761](https://github.com/googleapis/release-please/issues/2761), fix [#2892](https://github.com/googleapis/release-please/pull/2892) open, 17.11.2 is the latest release | +| `release-please` `build/src/changelog-notes/github.js` | `changelog-type: github` ignores commits and reads the whole tag range, which is wrong for component releases | +| Prototype run on the sandbox's releases, then a second run | every line gained `(@login)`; the second run changed nothing | +| Prototype dry run over `v5.10.0`'s real notes, read-only | all 44 lines credited: 33 `@blafourcade`, 7 `@dependabot[bot]`, 1 `@alexsoyes`, including lines ending in `, closes #…` | +| `gh pr list --base next` (60 PRs) and `gh api commits/` | a squash commit's author is the PR author (55/55 humans), even when someone else merges | +| `back-merge.yml` `on:` | triggers on `release: published` only. Editing a release fires `edited`, so crediting does not re-run the back-merge | +| Sandbox, ruleset `allowed_merge_methods: ["merge"]` | squash and rebase refused, merge accepted | +| Sandbox, bypass actor in `pull_request` mode (the live mode for `aidd-bot` and `admin`) | squash still refused, even with `--admin`. Only `always` mode allowed it | +| Sandbox, release PR under that ruleset | `--squash --admin` refused; `--merge --admin` merged and release-please tagged all three releases on the merge commit | +| Sandbox, back-merge `--no-ff` then a second cycle | no conflict; the next release carried only the new line, only for the touched component | +| Sandbox, squashed promote with the ruleset disabled | release-please logged "No user facing commits found": the week's entries vanished silently | +| `.../rulesets/12902947` | live `main protection` carries `allowed_merge_methods: [merge, squash, rebase]`, absent from `.github/rulesets/main.json`; nothing syncs the two | +| PR #135, commit `1919c7cd` | this repository already released from a merge-commit release PR (`v4.1.0`) | +| `@commitlint/is-ignored` `lib/defaults.js` | `Merge pull request …` subjects are ignored, so a merge commit on `main`'s tip passes commitlint | + +## Decisions + +| Decision | Why | +| --- | --- | +| Post-process the GitHub releases, not the native option | the native option is a no-op until upstream #2892 ships. The step carries a comment linking #2761 and #2892, and #911 records the follow-up: remove it once a fixed release-please is pinned | +| Resolve the author from each line's commit SHA, not its `#N` | the SHA is the commit release-please credited; a line can also carry `closes #N`. Matches the committer semantics agreed in brainstorm | +| No login resolves: append the author's name without `@` | same fallback as the native option | +| A line already ending in `(@…)` or a credited name is left alone | re-running the job never duplicates | +| Only the GitHub releases carry handles | `CHANGELOG.md` and the release PR body are written before the step runs. Accepted | +| `main` allows only merge commits | a ruleset cannot target a head branch. In `pull_request` bypass mode nobody, admins included, can squash or rebase a promote | +| The release PR merges with `--merge --admin` | `--squash --admin` is refused under the ruleset. `--admin` stays for approvals | +| Rollout order: the `--merge` release merge ships and proves itself before the live ruleset changes | applied first, the ruleset would block the next release | +| No post-merge net | the ruleset already blocks every actor in `pull_request` mode; only turning a bypass to `always` or disabling the ruleset reopens the gap | +| A `hotfix/*` lands as a merge commit | the one human flow that changes. Its subject is ignored by commitlint, and release-please reads its conventional commits | + +Tradeoffs the user accepts: + +- A `@login` in nine release bodies may notify each contributor every cycle. Not verified. +- `(@dependabot[bot])` appears on dependency lines. diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/backlog-link.json b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/backlog-link.json new file mode 100644 index 000000000..53c06a37d --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/backlog-link.json @@ -0,0 +1,5 @@ +{ + "backlog": "ai-driven-dev/framework#908", + "written_at": "2026-09-24T00:00:00Z", + "written_by": "aidd-dev:01-plan" +} diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-1.md new file mode 100644 index 000000000..7a3cd3cbe --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-1.md @@ -0,0 +1,91 @@ +--- +status: pending +--- + +# Instruction: Scaffold `aidd-qa` with the acceptance QA skill + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +plugins/aidd-qa/ +├── .claude-plugin/plugin.json ✅ name, version 0.1.0, description, skills[] +├── README.md ✅ concern, skill table, browser-only scope, install line +└── skills/01-acceptance-qa/ + ├── SKILL.md ✅ router: prerequisites → load-scope → prepare-run → run-scenarios + ├── actions/00-prerequisites.md ✅ moved from aidd-dev, unchanged behavior + ├── actions/01-load-scope.md ✅ rewritten: scenarios derive from acceptance criteria only + ├── actions/02-prepare-run.md ✅ moved, deterministic setup and teardown kept + ├── actions/03-run-scenarios.md ✅ moved, report fields expected/actual/verdict/evidence + ├── assets/qa-report-template.md ✅ one row per scenario: expected, actual, verdict, evidence + └── references/interface-browser-playwright-cli.md ✅ moved recording contract, names browser as the supported interface +``` + +## User Journey + +```mermaid +flowchart TD + A[Acceptance criteria + reviewed candidate] --> B[prerequisites] + B --> C[load-scope: one scenario per browser-observable criterion] + C --> D[prepare-run: auth, fixtures, teardown] + D --> E[run-scenarios: record, verdict, reset] + E --> F[qa.md + qa/*.webm] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + copy the plugin into a fresh marketplace checkout => plugin directory present: 5: system + section Happy path + run check-architecture-rules on plugins/aidd-qa => no violation: 5: cli + section Edge case - criterion not browser-observable + load-scope given a criterion with no browser outcome => criterion listed as out of interface, no scenario: 5: system +``` + +## Tasks to do + +### `1)` Manifest and README + +> The plugin declares itself and one skill. + +1. `plugin.json` from `aidd-ui`'s shape: `name: aidd-qa`, `version: 0.1.0`, description "Acceptance QA: validates observable behavior against acceptance criteria and records reviewer evidence. Use when … Do NOT use for …", `skills: ["./skills/01-acceptance-qa"]`, keywords. +2. `README.md`: concern, skill table, browser as the only interface today, `/plugin install aidd-qa@aidd-framework`. No sibling-plugin address. + +### `2)` Move the skill + +> Browser QA lives under `aidd-qa` with history kept. + +1. `git mv plugins/aidd-dev/skills/11-browser-qa plugins/aidd-qa/skills/01-acceptance-qa` (phase 2 recreates the redirect in `aidd-dev`). +2. Rename `references/run-scope-playwright-cli.md` to `references/interface-browser-playwright-cli.md`; update the link in `03-run-scenarios.md`. +3. `SKILL.md`: frontmatter `name: 01-acceptance-qa`, description stating input = acceptance criteria + reviewed candidate, browser interface; router table and transversal rules kept; add rule "never derive a scenario from the diff or the source code". + +### `3)` Acceptance-derived scope + +> Scenarios come from acceptance criteria, not from implementation. + +1. `01-load-scope` Input: acceptance criteria (issue, spec, plan) + reference to the reviewed candidate (branch, commit, or running URL). +2. Process: one happy path from the criteria's primary journey, edge cases only from criteria or the plan's browser Test Scope; each scenario keeps the criterion it proves; a criterion with no browser-observable outcome is listed as out of interface, never tested by reading code. +3. Remove steps that source edge cases from the implementation artifact or related tests. +4. Add a `## Test` section to every action that lacks one. + +### `4)` Report per scenario + +> Each scenario states expected, actual, verdict, evidence. + +1. `qa-report-template.md`: header verdict, source (acceptance criteria path), candidate, run date; table `Scenario | Criterion | Expected | Actual | Verdict | Evidence`. +2. `03-run-scenarios` Report step and Test reference those fields; video validation steps kept. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | `plugin.json` validates against its schema; no `aidd-:` of another plugin in README | +| 2 | `plugins/aidd-qa/skills/01-acceptance-qa/` holds 4 actions, 1 asset, 1 reference; `check-architecture-rules.js` passes | +| 3 | `01-load-scope` names acceptance criteria as its only scenario source and forbids diff or source-derived scenarios | +| 4 | the report template has Expected, Actual, Verdict, Evidence columns; video checks (codec, dimension, duration, frames) still present | diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-2.md new file mode 100644 index 000000000..9c22452b1 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-2.md @@ -0,0 +1,66 @@ +--- +status: pending +--- + +# Instruction: Retire `aidd-dev:11-browser-qa` to a redirect + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +plugins/aidd-dev/ +├── .claude-plugin/plugin.json ✏️ description drops Browser QA; skills[] keeps ./skills/11-browser-qa +├── README.md ✏️ Browser QA row becomes a moved note +└── skills/11-browser-qa/ + ├── SKILL.md ✅ retired redirect router, one action + └── actions/01-redirect.md ✅ prints the migration message and stops +aidd_docs/memory/testing.md ✏️ Browser QA owner is aidd-qa +``` + +## User Journey + +```mermaid +flowchart TD + A[User invokes the old Browser QA skill] --> B[redirect action] + B --> C[Message: moved to the aidd-qa plugin + install command] + C --> D[Stop, no QA run] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Happy path + invoke the retired skill => migration message naming aidd-qa and its install command: 5: system + section Edge case - description matching + ask for browser QA with both plugins installed => description declares itself retired and not for running QA: 5: system +``` + +## Tasks to do + +### `1)` Redirect skill + +> The old invocation answers with a migration message. + +1. `SKILL.md`: `name: 11-browser-qa`, description "Retired. Explains where browser QA moved. Use only when this skill is invoked by name. Do NOT use to run QA or record evidence." Actions table with `redirect`. +2. `actions/01-redirect.md`: Input none; Output the message "Browser QA moved to the `aidd-qa` plugin. Install it with `/plugin install aidd-qa@aidd-framework` (or `aidd plugin install aidd-qa`) and run its acceptance QA skill."; Process print and stop, never run QA; `## Test`. +3. No `aidd-:` token anywhere in the redirect. + +### `2)` aidd-dev surface + +> aidd-dev no longer claims Browser QA. + +1. `plugin.json` description: remove "plus short standalone Browser QA evidence". +2. `README.md`: remove Browser QA from the covers sentence; row 2.11 says retired, moved to the `aidd-qa` plugin. +3. `aidd_docs/memory/testing.md`: owner `aidd-qa`, skill `01-acceptance-qa`. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | the redirect holds no QA process; `check-architecture-rules.js` passes on it | +| 2 | `grep -i "browser qa" plugins/aidd-dev/.claude-plugin/plugin.json` finds nothing; memory names `aidd-qa` as owner | diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-3.md b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-3.md new file mode 100644 index 000000000..cb03aef56 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-3.md @@ -0,0 +1,70 @@ +--- +status: pending +--- + +# Instruction: Register the plugin and update the docs + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +.claude-plugin/marketplace.json ✏️ aidd-qa entry, strict, metadata.recommended false +release-please-config.json ✏️ plugins/aidd-qa package +.release-please-manifest.json ✏️ "plugins/aidd-qa": "0.1.0" +.github/workflows/ci.yml ✏️ build-plugin matrix gains aidd-qa +commitlint.config.cjs ✏️ scope-enum gains aidd-qa, qa +docs/ARCHITECTURE.md ✏️ concerns table row: aidd-qa, Acceptance QA, Execution + status note +docs/CATALOG.md ✏️ aidd-qa section; Browser QA row removed from aidd-dev +README.md ✏️ plugin counts, aidd-qa section, aidd-dev line drops Browser QA +aidd_docs/memory/architecture.md ✏️ 9 plugins, 3 off the curated path +aidd_docs/memory/project-brief.md ✏️ key feature row for acceptance QA +``` + +## User Journey + +```mermaid +flowchart TD + A[marketplace.json lists aidd-qa] --> B[release-please versions it] + B --> C[ci.yml builds its archive] + A --> D[docs and taxonomy name it] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Happy path + run the scripts suite => release-covers-every-plugin and architecture-doc tests pass: 5: cli + section Edge case - curated install + read marketplace.json => aidd-qa has recommended false: 5: system +``` + +## Tasks to do + +### `1)` Registration + +> Every release and CI guard sees the plugin. + +1. Marketplace entry after `aidd-telemetry`, description by concern, `recommended: false`. +2. release-please package block copied from `plugins/aidd-ui`; manifest `0.1.0`. +3. `ci.yml` matrix and commitlint scopes. + +### `2)` Docs and memory + +> Every place that counts or lists plugins stays true. + +1. `docs/ARCHITECTURE.md` row + a one-line status note (off the curated path until proven). +2. `docs/CATALOG.md` and `README.md`: add aidd-qa, fix counts and the aidd-dev description, keep the README badge/status style used for `aidd-ui`/`aidd-telemetry`. +3. Memory `architecture.md` gotcha count, `project-brief.md` feature row. +4. Let `pnpm exec lefthook run pre-commit` regenerate catalogs and counts; never hand-edit generated files. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | `release-covers-every-plugin.test.js` passes; marketplace JSON validates | +| 2 | `architecture-doc-matches-the-tree.test.js` passes; no doc still says 8 plugins or attributes Browser QA to aidd-dev | diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-4.md b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-4.md new file mode 100644 index 000000000..0aec832d1 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-4.md @@ -0,0 +1,62 @@ +--- +status: pending +--- + +# Instruction: Prove it installs and translates to a second host + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/validation.md ✅ commands run and their decisive output +``` + +## User Journey + +```mermaid +flowchart TD + A[Gates] --> B[claude plugin validate plugins/aidd-qa] + B --> C[cli build + aidd translate to Codex] + C --> D[validation.md] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + build the CLI => dist/cli.js present: 5: cli + section Happy path + translate the marketplace to codex into the scratchpad => aidd-qa skill, actions, asset, reference present: 5: cli + section Edge case - Claude Code manifest + claude plugin validate plugins/aidd-qa => valid: 5: cli +``` + +## Tasks to do + +### `1)` Gates + +> Every repository gate is green. + +1. `pnpm exec lefthook run pre-commit`. +2. `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'`. +3. `pnpm test:changed`. + +### `2)` Host proof + +> The plugin works in Claude Code and Codex. + +1. `claude plugin validate plugins/aidd-qa` (and the marketplace root). +2. `cd cli && pnpm install && pnpm build`; read `node cli/dist/cli.js translate --help`; translate to Codex into the scratchpad; list the aidd-qa output. +3. Record commands and decisive lines in `validation.md`. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | all three commands exit 0 | +| 2 | Claude Code validation passes and the Codex output contains the acceptance QA skill with its four actions | diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/plan.md b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/plan.md new file mode 100644 index 000000000..19987b003 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/plan.md @@ -0,0 +1,46 @@ +--- +objective: "An installable, off-curated-path aidd-qa plugin owns browser acceptance QA derived from acceptance criteria, and aidd-dev no longer carries browser QA." +status: implemented +--- + +# Plan: aidd-qa plugin + +## Overview + +| Field | Value | +| --- | --- | +| **Goal** | Create `aidd-qa`, move Browser QA into it as acceptance QA, remove the `aidd-dev` skill, register the plugin everywhere a plugin is registered | +| **Source** | https://github.com/ai-driven-dev/framework/issues/908 | + +## Phases + +| # | Phase | File | +| --- | --- | --- | +| 1 | Scaffold `aidd-qa` with the acceptance QA skill | [`phase-1.md`](./phase-1.md) | +| 2 | Retire `aidd-dev:11-browser-qa` to a redirect | [`phase-2.md`](./phase-2.md) | +| 3 | Register the plugin and update the docs | [`phase-3.md`](./phase-3.md) | +| 4 | Prove it installs and translates to a second host | [`phase-4.md`](./phase-4.md) | + +## Resources + +| Source | Verified | +| --- | --- | +| `docs/CREATE_PLUGIN.md` | registration = marketplace entry with `metadata.recommended: false`, release-please config and manifest; no cross-plugin reference in descriptions or READMEs | +| `docs/ARCHITECTURE.md` + `scripts/__tests__/architecture-doc-matches-the-tree.test.js` | the concerns table must hold one row per plugin in the tree | +| `scripts/__tests__/release-covers-every-plugin.test.js` | `ci.yml` `build-plugin` matrix and `release-please-config.json` must list every marketplace plugin | +| `scripts/lib/architecture-rules.js` (`PLUGIN_ADDRESS`) | any `aidd-:` token (optional `/` or `@`) in a skill, action, reference or agent of another plugin is an orthogonality violation; a bare plugin name or `aidd-qa@aidd-framework` is not | +| commit 627408fb (aidd-telemetry added) | touchpoints for a new plugin: marketplace, release manifest, README, memory; `.claude/settings.json` enables only curated plugins | +| PR #512 (Browser QA landed) | `aidd-vcs` pull-request draft links `**/qa/*.webm`; keeping the `qa/` evidence folder name keeps that link working | + +## Decisions + +| Decision | Why | +| --- | --- | +| Skill `aidd-qa:01-acceptance-qa`, browser as its only interface, declared in an interface reference | the entry point is named by intention (acceptance validation), so API or CLI interfaces can be added later without renaming; none is claimed now | +| Layer Execution in the taxonomy | it drives the running application, which the Knowledge firewall forbids | +| `aidd-dev:11-browser-qa` is removed after a first redirect iteration | `aidd-dev` owns no QA surface; `aidd-qa` is the single owner | +| `aidd-dev:06-test` `test-journey` stays in `aidd-dev` | it is developer-side validation the SDLC Deliver zone runs before commit, not independent acceptance evidence; moving it is outside #908 | +| Evidence folder stays `qa/` with `happy-path.webm` and `edge-case-.webm` | the pull-request draft already links `**/qa/*.webm` | +| First release tagged `aidd-qa-v1.0.0` via `release-as` on the package, manifest at `0.1.0` | a `Release-As` footer on the squash commit would also re-version every other path it touches; drop `release-as` once `v1.0.0` ships | +| Not added to `.claude/settings.json` `enabledPlugins` | that list holds only curated plugins; `aidd-ui` and `aidd-telemetry` are absent too | +| Commits split by path | release-please bumps per path from the commit type | diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/validation.md b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/validation.md new file mode 100644 index 000000000..2f361f8ee --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/validation.md @@ -0,0 +1,152 @@ +--- +status: done +--- + +# Validation: aidd-qa plugin + +Commands run from the repository root unless noted, with their decisive output line. The original "Gates", "Host proof", and "Architecture conformance" tables below were all run against that implementation's own final working tree, before it was split into its first five commits — not re-run per commit. Each commit's own `pre-commit` hook run exited 0 (a non-zero exit would have aborted the commit), but that hook reads the working tree at commit time, not a diff scoped to that commit's own files; see "Commits" below for what that means for the two intermediate trees. "Repair re-run", further down, records a separate, later run against this repair's own final tree — read its own heading for which tree it covers, not this paragraph. + +Review #908 found five defects after the push recorded in the original "Push" section: a missing run-verdict rule in `03-run-scenarios.md`, a `load-scope` that never stopped early when no criterion is browser-observable, a duplicate `## Test` bullet, two hand-written docs still describing `06-test` as generic test coverage after `ba2a59a5` narrowed it, and this record's own commit table and push section going stale as later commits (`ba2a59a5`, `e8d3538e`, `cff70e66`) landed without an update. Fixing the second defect had a second-pass bug: the first attempt at a `load-scope`-skips-`prerequisites` rule left the router's own action order (`00-prerequisites` before `01-load-scope`) unchanged, so the rule was aspirational rather than true; a second pass moved the criteria check ahead of `00` in `SKILL.md` itself. The docs-wording fix for defect four also had a first-pass bug: it copied this task's own writing constraint ("no sibling-plugin address") into the README and CATALOG rows as if it described the skill, corrected once caught. Both are visible as separate commits below rather than folded into the commits that introduced them, since those commits were already pushed. + +## Gates (phase 4, task 1) + +| # | Command | Exit | Decisive output | +| --- | --- | --- | --- | +| 1 | `pnpm exec lefthook run pre-commit` (final clean sweep, all task files staged) | 0 | `summary: (done in 48.22 seconds)` — `check-skill-argument-hints`, `cli-architecture`, `doc-duplication`, `markdown-links`, `referenced-paths`, `scripts-tests`, `summarize-plugin-catalogs`, `summarize-telemetry-prompts-doc`, `sync-readme-counts` all `✔️`; embedded `scripts-tests` run: `ℹ tests 503` / `ℹ pass 503` / `ℹ fail 0` | +| 2 | `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` | 0 | `ℹ tests 503` / `ℹ pass 501` / `ℹ fail 0` / `ℹ skipped 2` — the 2 skips are named `hands a broken Biome rule back to the agent, naming it` and `formats a file in place and stays silent on what would not block a commit` (`# SKIP`), both pre-existing Biome-gated CLI tests, unrelated to this change | +| 3 | `pnpm test:changed` | 0 | every project block reports `ℹ fail 0`; final combined run `ℹ fail 0` | + +`pre-commit`'s glob-scoped jobs (`architecture-rules`, `json-validity`, `yaml-validity`, `skill-frontmatter`) reported "no files for inspection" on the staged-file glob lefthook resolved in this checkout, so they did not execute inside that run. Ran explicitly instead: `pnpm exec lefthook run pre-commit --all-files --job architecture-rules --job json-validity --job yaml-validity --job skill-frontmatter` → exit 0, `✅ Architecture rules: 431 governed file(s) checked, no violation`, `JSON validation passed for 79 file(s).`, `YAML validation passed for 22 file(s).`, `skill-frontmatter` ✔️ with no breach printed. + +Root `pnpm install` and `cd cli && pnpm install` were run first — neither `node_modules` existed in this worktree, so `js-yaml` (root) and `vitest` (`cli`) were missing and `cli-architecture` failed with `vitest: command not found` (exit 127) until installed. Not a regression from this change; recorded because it would otherwise have looked like an empty-gate false pass. + +One scripts-suite regression was found and fixed as part of this change: `scripts/__tests__/architecture-rules.test.js` pins `skillsWithActions` to the number of skills with an `actions/` dir, swept from the real `plugins/` tree. Adding `aidd-qa:01-acceptance-qa` raises that from 48 to 49; the assertion was updated to `49` (test intent unchanged — it still fails if a sweep silently misses a skill). + +### Repair re-run (review #908 follow-up) + +Each command below was run with its exit code captured directly (`cmd > log 2>&1; echo EXIT=$?`), not through a wrapper that could mask it. This is the last of two re-runs during this repair; the first (after the three initial fixes, before the `SKILL.md` routing and docs-wording corrections) produced the same decisive numbers, so only this final one, against this repair's actual final tree (`docs/CATALOG.md`, `plugins/aidd-dev/README.md`, and `plugins/aidd-qa/skills/01-acceptance-qa/{SKILL.md,actions/01-load-scope.md,actions/03-run-scenarios.md}` staged together), is recorded: + +| # | Command | Exit | Decisive output | +| --- | --- | --- | --- | +| 1 | `pnpm exec lefthook run pre-commit` | 0 | `summary: (done in 48.73 seconds)` — `check-skill-argument-hints`, `doc-duplication`, `markdown-links`, `referenced-paths`, `scripts-tests`, `summarize-plugin-catalogs`, `summarize-telemetry-prompts-doc`, `sync-readme-counts` all `✔️`; embedded `scripts-tests` run: `ℹ tests 503` / `ℹ pass 503` / `ℹ fail 0` / `ℹ skipped 0` | +| 2 | `node scripts/check-architecture-rules.js` (no args, whole governed tree) | 0 | `✅ Architecture rules: 352 governed file(s) checked, no violation` | +| 3 | `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` | 0 | `ℹ tests 503` / `ℹ suites 22` / `ℹ pass 503` / `ℹ fail 0` / `ℹ skipped 0` | +| 4 | `claude plugin validate plugins/aidd-qa` | 0 | `✔ Validation passed` | + +Both scripts-suite entries in this re-run agree on one number, 503 tests / 503 pass / 0 fail / 0 skipped — the earlier 503-pass-vs-501-pass-plus-2-skip split recorded above (gates 1 and 2, phase 4) no longer reproduces on this tree. `architecture-rules`, `json-validity`, `skill-frontmatter`, and `yaml-validity` again reported "no files for inspection" against lefthook's staged-file glob in this checkout, the same quirk noted above; `check-architecture-rules.js` was run explicitly instead, as this task's dispatch required, rather than via `--all-files --job`. `summarize-plugin-catalogs` and `sync-readme-counts` ran but changed nothing — `git status` after each pre-commit run showed no file beyond the ones already staged. + +### Second repair re-run (review #908, second follow-up) + +Commands below were run with their exit code captured directly (`cmd > log 2>&1; echo EXIT=$?`), against this round's tree with commit 15 (`SKILL.md`, `01-load-scope.md`, `02-prepare-run.md`, `03-run-scenarios.md`, `qa-report-template.md`) already landed and this file staged alone for commit 16: + +| # | Command | Exit | Decisive output | +| --- | --- | --- | --- | +| 1 | `pnpm exec lefthook run pre-commit` | 0 | `summary: (done in 3.27 seconds)` — `doc-duplication`, `markdown-links`, `referenced-paths`, `summarize-plugin-catalogs`, `summarize-telemetry-prompts-doc`, `sync-readme-counts` all `✔️`; `architecture-rules`, `check-skill-argument-hints`, `json-validity`, `scripts-tests`, `skill-frontmatter`, `yaml-validity` skipped — this round's only staged file for this run is this task doc, which matches none of their globs | +| 2 | `node scripts/check-architecture-rules.js` (no args, whole governed tree) | 0 | `✅ Architecture rules: 352 governed file(s) checked, no violation` | +| 3 | `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` | 0 | `ℹ tests 503` / `ℹ suites 22` / `ℹ pass 503` / `ℹ fail 0` / `ℹ skipped 0` | +| 4 | `claude plugin validate plugins/aidd-qa` | 0 | `✔ Validation passed` | + +`git status` after the pre-commit run showed no file beyond `validation.md`, already staged — `summarize-plugin-catalogs` and `sync-readme-counts` changed nothing, since commit 15 touched no `CATALOG.md`-governed surface. + +## Host proof (phase 4, task 2) + +| # | Command | Exit | Decisive output | +| --- | --- | --- | --- | +| 1 | `claude plugin validate plugins/aidd-qa` | 0 | `✔ Validation passed` | +| 2 | `claude plugin validate plugins/aidd-dev` | 0 | `✔ Validation passed` (redirect skill included) | +| 3 | `claude plugin validate .` | 0 | `✔ Validation passed` (marketplace root, 9 plugins) | +| 4 | `cd cli && pnpm install && pnpm build` | 0 | `Bundle size: 727.1 KB / budget: 734 KB` / `OK: within budget` | +| 5 | `node cli/dist/cli.js translate --help` | 0 | `--to Conversion target (claude, cursor, copilot, codex, opencode, kilo)` | +| 6 | `node cli/dist/cli.js translate . --to codex --out /translate-codex --as marketplace` | 0 | `Built 9 plugins, 477 files written to /translate-codex` | +| 7 | `HOME=/install-sandbox/home node cli/dist/cli.js setup --source local --path --ai claude,codex --plugins none --yes --scope project` (run from an empty `/install-sandbox/project`, criterion 10) | 0 | `Installed claude, codex (2 files)` | +| 8 | `HOME=/install-sandbox/home node cli/dist/cli.js plugin install aidd-qa --tool claude --scope project --yes` then the same with `--tool codex` (criterion 10) | 0 (both) | `Warning: Native plugin activation — upgrade marketplace 'aidd-framework' skipped: codex marketplace upgrade aidd-framework failed: Error: marketplace aidd-framework is not configured as a Git marketplace` then `Installed 'aidd-qa'.` (both tools; the warning is expected — the sandbox's marketplace source is local, not Git) | + +Gate 8's install landed the full skill surface under both tools' caches — `find /install-sandbox/home/.claude/plugins/cache/aidd-framework/aidd-qa/0.1.0/skills/01-acceptance-qa -name '*.md'` lists `SKILL.md` plus its 4 actions, 1 asset, 1 reference, matching the phase-1 projection and gate 6's Codex translate listing; `diff` against the working tree's own `SKILL.md` at commit 15's tree reported no difference, so the installed copy carries this round's Rejected-section and skip-destination fix, not a stale cached one. + +Translated output confirms the full skill surface reached Codex: + +``` +plugins/aidd-qa/.codex-plugin/plugin.json +plugins/aidd-qa/skills/01-acceptance-qa/SKILL.md +plugins/aidd-qa/skills/01-acceptance-qa/actions/00-prerequisites.md +plugins/aidd-qa/skills/01-acceptance-qa/actions/01-load-scope.md +plugins/aidd-qa/skills/01-acceptance-qa/actions/02-prepare-run.md +plugins/aidd-qa/skills/01-acceptance-qa/actions/03-run-scenarios.md +plugins/aidd-qa/skills/01-acceptance-qa/assets/qa-report-template.md +plugins/aidd-qa/skills/01-acceptance-qa/references/interface-browser-playwright-cli.md +``` + +4 actions, 1 asset, 1 reference — matches the phase-1 architecture projection. + +## Architecture conformance + +| Command | Exit | Decisive output | +| --- | --- | --- | +| `node scripts/check-architecture-rules.js $(find plugins/*/skills plugins/*/agents -name '*.md')` | 0 | `✅ Architecture rules: 433 governed file(s) checked, no violation` | +| `node --test scripts/__tests__/architecture-doc-matches-the-tree.test.js` | 0 | `the plugin concerns table has one row per plugin in the tree` passes | +| `node --test scripts/__tests__/release-covers-every-plugin.test.js` | 0 | `build-plugin builds an archive for every plugin the marketplace lists` and `release-please versions every plugin the marketplace lists` both pass | +| `node scripts/check-doc-duplication.js` | 0 | `✅ Doc duplication: 0 duplicated sentence(s) in 48 files` | +| `node scripts/check-referenced-paths.js` | 0 | `✅ Referenced paths: 0 dead in 34 files` | +| `node scripts/check-skill-argument-hints.mjs` | 0 | `Every skill names what the user brings.` | +| `node scripts/check-markdown-links.js --ignore cli/tests/fixtures --ignore cli/aidd_docs/tasks` | 0 | `✅ Links: 0 broken in 791 files` | + +`/aidd-dev:03-assert`'s `assert-architecture` facet (report-only): no macro violation — `plugins/aidd-qa/` matches the documented plugin anatomy (`.claude-plugin/plugin.json` + `skills/01-acceptance-qa/{SKILL.md, actions/, assets/, references/}`, no unused optional surfaces); no micro violation — the skill's action files carry no cross-plugin address. `assert-frontend` was skipped: this change ships markdown only, no running UI to drive. + +No `aidd-:` token for another plugin appears in `plugins/aidd-qa/**` or `plugins/aidd-dev/skills/11-browser-qa/**`; the redirect names only the bare `aidd-qa` plugin and `/plugin install aidd-qa@aidd-framework` (no colon after `aidd-qa`, so `PLUGIN_ADDRESS` does not match it). + +## Commits + +All commits on this branch (`git log origin/next..HEAD`), oldest first. Rows 1-8 were pushed before the first repair started (`cff70e66` confirmed reaching `origin` at that push, original "Push" section below). Rows 9-14 are that first repair's, each carrying its real local SHA `git rev-parse --short=8 HEAD` printed right after its own `git commit`, captured into this table before the next commit ran; row 14's SHA (`3c81dfb7`) is filled in now that it is known, though the commit that first wrote this row could not yet state it. Rows 15-16 are a second round, driven by this review's follow-up findings on load-scope's skip destination, run-scenarios' verdict ordering for a rejected scenario set, and this record's own defect count and commit table; row 16, this file's own commit, cannot state its own SHA — read it off `git log` or `git ls-remote` on the pushed branch. + +| # | SHA | Subject | +| --- | --- | --- | +| 1 | `3ef728a7` | `feat(aidd-qa): scaffold acceptance QA plugin from browser QA` | +| 2 | `ffb9c9c5` | `feat(aidd-dev): retire browser-qa to a redirect` | +| 3 | `50c13400` | `chore(marketplace): register aidd-qa plugin` | +| 4 | `f5c3dfcb` | `docs(aidd-qa): add the plan and its validation record` | +| 5 | `cd048e6b` | `docs(aidd-qa): correct the validation record` | +| 6 | `ba2a59a5` | `fix(aidd-dev): scope 06-test to developer-side validation` | +| 7 | `e8d3538e` | `fix(aidd-qa): restrict criteria sourcing and complete the report contract` | +| 8 | `cff70e66` | `docs: correct plugin count and the pushed validation record` | +| 9 | `89243332` | `fix(aidd-qa): add run-verdict rule and stop load-scope early` — `03-run-scenarios.md`, `01-load-scope.md`, `SKILL.md` | +| 10 | `db49deaa` | `fix(aidd-dev): describe 06-test as developer-side in its README` | +| 11 | `b642829f` | `docs: correct 06-test's catalog description and the validation record` — `docs/CATALOG.md`, this file (an earlier draft of it) | +| 12 | `911bcab5` | `fix(aidd-qa): route the zero-criterion skip before prerequisites` — `SKILL.md`, correcting the routing bug commit 9's transversal rule left in place, and correcting commit 9's own commit-body claim about where `load-scope` stops | +| 13 | `a6e0cda4` | `fix(aidd-dev): drop the addressing note from the 06-test README row` — correcting a writing-constraint phrase commit 10 copied into product-facing text | +| 14 | `3c81dfb7` | `docs: correct 06-test's catalog description and the validation record` — `docs/CATALOG.md`, this file | +| 15 | `ec1cf16f` | `fix(aidd-qa): report a fully rejected scenario set as blocked, not skipped` — `SKILL.md`, `01-load-scope.md`, `02-prepare-run.md`, `03-run-scenarios.md`, `qa-report-template.md`, correcting review #908's follow-up warning about a scenario set emptied by rejection | +| 16 | (this round's final docs commit — this record) | `docs: correct the validation record's defect count and commit table, add install proof` — this file | + +`summarize-plugin-catalogs` and `sync-readme-counts` regenerate `plugins/*/CATALOG.md` and README's counts block from the live working tree, not from the commit's own staged diff — the working tree already held the final content when commit 1 ran, so `plugins/aidd-dev/CATALOG.md` in commit 1 already describes the redirect that only lands in commit 2. That is a known, accepted side effect; it does not change what either commit's hand-authored content says. It also means the two intermediate trees are not independently clean against the gates in this file: + +- **At commit 1:** `plugins/aidd-dev/.claude-plugin/plugin.json` still lists `"./skills/11-browser-qa"` in `skills[]`, but that tree has no `plugins/aidd-dev/skills/11-browser-qa/` directory (it moved to `aidd-qa` in this same commit, and the redirect is not added until commit 2). `scripts/__tests__/architecture-rules.test.js`'s `skillsWithActions` sweep would read `48` on this tree, not the `49` the pinned assertion (also changed in commit 1) expects — the pin only becomes true at commit 2, once the redirect's own `actions/` directory exists. This was a mistake in how the pin's commit placement was chosen, caught only while writing this correction, not fixed by rewriting unpushed history. +- **At commit 1 and 2:** no `aidd-qa` entry exists yet in `.claude-plugin/marketplace.json`, so `release-covers-every-plugin.test.js` and `architecture-doc-matches-the-tree.test.js`'s concerns-table check would fail on those trees in isolation (9 plugin directories, 8-row concerns table / 8-plugin marketplace). + +The commits were not restructured to fix this: the tree every gate in the original "Gates", "Host proof", and "Architecture conformance" tables above was actually run against is that implementation's final one (after commit 3), and splitting further would move the same CATALOG-regeneration mismatch somewhere else rather than remove it. The same applies to the first repair's own six commits (9-14): "Repair re-run" above was run once, against that repair's final tree, not per commit. The second round's own two commits (15-16) were checked the same way; see "Second repair re-run" below. + +## Push + +`git push -u origin feat/aidd-qa-plugin` first failed on `pnpm exec lefthook run pre-push` (glob `cli/**` — see below for why it ran), at `cli-test`, before any network call: + +| Job | Result | +| --- | --- | +| `cli-knip` | ✔️ | +| `cli-test` (`pnpm --dir cli test`) | ✖ `Test Files 1 failed \| 528 passed \| 1 skipped (530)` / `Tests 1 failed \| 6794 passed \| 1 skipped (6796)`, decisive line: `tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts:51 AssertionError: expected '' not to be ''` | + +The failing assertion is `E2E: the sandbox a test spawns into > still reaches node and git, which the code under test genuinely needs`; under this test's synthetic sandboxed `PATH`, `which node` returns nothing. Isolated it and confirmed: + +- Root cause, verified rather than guessed: `ls "$(dirname "$(node -p 'process.execPath')")" | grep -xE 'opencode|claude|codex|copilot|cursor-agent'` printed `codex` — this machine's `node` (via nvm) shares a `bin/` directory with a `codex` binary. `pathWithoutAidd()` in `cli/tests/e2e/helpers.ts` builds the sandbox `PATH` from `dirname(process.execPath)` among others, then runs `.filter(withoutDrivableToolBinary)`, which drops any directory holding an AI-tool binary — dropping node's own directory along with it because `codex` sits next to it. Machine-specific: `git diff c3a3355f..HEAD --stat -- cli/` is empty (none of this branch's commits touch `cli/`), and the failing test file was last changed in `95bdbbc3` (2026-09-09), weeks before this task — a pre-existing local gap, not a regression. + +No workaround that bypasses or weakens the gate was used: no `--no-verify`, no excluding the job, no editing the test. Instead, the collision itself was fixed for this shell: the `node` binary was copied — not symlinked, since `process.execPath` resolves a symlink back to the original, `codex`-sharing directory — into an isolated directory holding no AI-tool binary, which was then prepended to `PATH` for the push. With that `PATH`, `pnpm exec lefthook run pre-push` passed in full (`cli-knip` ✔️, `cli-test` all passing, no failing file), and `git push -u origin feat/aidd-qa-plugin` completed without `--no-verify`. `cd048e6b` and every commit before it on this branch reached `origin` at that push; confirmed with `git ls-remote origin refs/heads/feat/aidd-qa-plugin` printing that SHA. + +This record does not have first-hand detail on how `ba2a59a5`, `e8d3538e`, and `cff70e66` individually reached `origin` — they were not pushed in this session. What is directly known: the session that made this repair started with `cff70e66` already at `origin/feat/aidd-qa-plugin` (its own environment snapshot reported the branch as pushed at that SHA before any repair commit existed), and this repair's own push used the same mechanism as the one detailed above — an isolated directory holding only a copied `node` binary, prepended to `PATH` for `git push`, never `--no-verify` — because the local `node`/`codex` collision this shell sits on has not changed. That push's `pnpm exec lefthook run pre-push` ran `cli-knip` and `cli-test` again (this repair changed no file under `cli/`); its own exit code and `git ls-remote` output are the evidence, not a claim repeated here. This file does not track a single frozen "pushed tip" SHA: the branch's tip is whatever `HEAD` is when the pull request is opened, i.e. the last row of the "Commits" table above at that time. `git ls-remote origin refs/heads/feat/aidd-qa-plugin` is the way to read it, not this paragraph. + +## Deviations from the plan + +- Updated `scripts/__tests__/architecture-rules.test.js`'s pinned `skillsWithActions` count (48 → 49) — not named in any phase file, required because the suite hardcodes a measured count that a new skill-with-actions legitimately changes. +- Updated `docs/MAINTAINERS.md`'s package count line (`10 packages (root + 8 plugins + cli)` → `11 packages (root + 9 plugins + cli)`) — not named in phase-3, but it is the same fact `deployment.md` states and would otherwise go stale. +- `plugins/aidd-qa/README.md` and `docs/CATALOG.md`'s new `aidd-qa` section do not use the `[N.x]` "Bracket ID" numbering the curated plugins use: `aidd-telemetry`, the other off-curated-path plugin, never adopted that convention either (confirmed by grep — no `[8.x]` rows exist in its README), so `aidd-qa` follows the same off-curated precedent rather than inventing a `[9.x]` series nobody else has used since telemetry landed. +- Root `README.md`'s "Plugins" intro changed from "install all of them" to "install the six stable ones", and the Claude Code install line's off-curated parenthetical grew a third name (`aidd-qa`). The plan asked only for a new tile and corrected counts; this wording change was made because "install all of them" was already inaccurate before this change (it excluded `aidd-ui` and `aidd-telemetry`, both already off the curated path) and adding a third off-curated plugin made the inaccuracy harder to ignore. Flagging it as a judgment call beyond the plan's literal scope rather than reverting it silently. +- `/aidd-dev:02-implement` was not invoked as a skill; the phases were implemented directly and validated against each phase's own "Test acceptance criteria" table by hand. `/aidd-dev:03-assert` was invoked and its two applicable facets (`01-assert`, `02-assert-architecture`) run as reported above; `03-assert-frontend` was skipped with a stated reason. +- `phase-1.md` through `phase-4.md` are committed with their original `status: pending` frontmatter unchanged. Only `plan.md`'s `status` was set to `implemented`, per this dispatch's explicit instruction; no instruction named a phase-file status convention, and none was invented. +- `/aidd-vcs:01-commit` was invoked through the Skill tool for commit 1 only, which surfaced its `01-collect` / `02-message` / `03-commit` process. Commits 2-5 followed that same process by hand (stage the concern's files, message from the imposed text, `git commit`, verify with `git show --stat`) without re-invoking the skill each time. The review #908 repair (commits 9-14) invoked `/aidd-vcs:01-commit` through the Skill tool once for commit 9, then followed the same by-hand process for commits 10-14. The second round (commits 15-16) invoked `/aidd-vcs:01-commit` through the Skill tool for commit 15, then followed the same by-hand process for commit 16. diff --git a/cli/.claude/skills/framework/SKILL.md b/cli/.claude/skills/framework/SKILL.md index 45c2ac67d..cad1cfce3 100644 --- a/cli/.claude/skills/framework/SKILL.md +++ b/cli/.claude/skills/framework/SKILL.md @@ -24,7 +24,7 @@ disk is exactly the job that needs all three. | Concept | Location | |---|---| -| The manifest aggregate and its members | `domain/manifest.ts`, `domain/manifest/` (tool-entry, tracked-files, merge-files, mcp-exclusions, native-registrations) | +| The manifest aggregate and its members | `domain/manifest.ts`, `domain/manifest/` (tool-entry, tracked-files, merge-files, native-registrations) | | A plugin's declared state | `domain/plugins/` (installed-plugin, source-resolver, requested-version-policy) | | The diagnosis shape | `domain/doctor.ts` | | Setup orchestration state | `domain/setup-flow.ts` | diff --git a/cli/README.md b/cli/README.md index 846943774..0013c0390 100644 --- a/cli/README.md +++ b/cli/README.md @@ -3,7 +3,7 @@ `@ai-driven-dev/cli` installs AI tool runtime configs, IDE integrations, and plugins from an AIDD marketplace into a project. Every file it writes is hash-tracked in a manifest, so drift is detected and owned files can be restored. -Supported AI tools: Claude Code, Cursor, GitHub Copilot, Codex, OpenCode. Supported IDE: VS Code. +Supported AI tools: Claude Code, Cursor, GitHub Copilot, Codex, OpenCode, Kilo Code. Supported IDE: VS Code. Requires Node.js >= 22.12, and `git` to fetch marketplace plugins. ## Install @@ -136,8 +136,9 @@ Two output layouts, chosen by `--as`: | `copilot` | yes | yes | `.github/` | | `codex` | yes | yes | `.codex/` | | `opencode` | no | yes | `.opencode/` | +| `kilo` | no | yes | `.kilo/` | -OpenCode declares no marketplace contract, so it is flat only. Every other target accepts both layouts. +OpenCode and Kilo Code declare no marketplace contract, so they are flat only. Every other target accepts both layouts. ## Environment variables @@ -184,6 +185,7 @@ Per tool, the settings file the CLI writes: | GitHub Copilot | Plugin recommendations in `.github/copilot/settings.json`, MCP servers in `.vscode/mcp.json`, plus `.vscode/settings.json` when the VS Code tool is installed too | | Codex | `.codex/config.toml` | | OpenCode | `opencode.json`, or `opencode.jsonc` when that is the one present | +| Kilo Code | `.kilo/kilo.jsonc` by default; an existing `kilo.json[c]` is reused | | VS Code | `.vscode/settings.json`, `.vscode/extensions.json`, `.vscode/keybindings.json` | ## More diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md index 90fe9aac2..cffa92725 100644 --- a/cli/aidd_docs/memory/codebase-map.md +++ b/cli/aidd_docs/memory/codebase-map.md @@ -21,6 +21,7 @@ src/ │ │ │ ├── global/ │ │ │ ├── install/ │ │ │ │ └── content/ +│ │ │ ├── ownership/ # machine plugin claims and safe project detach │ │ │ ├── plugin/ │ │ │ ├── restore/ │ │ │ ├── setup/ @@ -51,6 +52,7 @@ src/ │ │ │ ├── codex/ │ │ │ ├── copilot/ │ │ │ ├── cursor/ +│ │ │ ├── kilo/ │ │ │ ├── opencode/ │ │ │ └── vscode/ │ │ └── infrastructure/ diff --git a/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md b/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md index 0d6f926f4..911afb58f 100644 --- a/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md +++ b/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md @@ -8,14 +8,16 @@ 2. Tracked files, merge files, plugin files. 3. `.aidd/` itself. A host needs `.aidd/cache/` alive during step 1. 4. Machine-local files no `plugins[].files` tracks: `.claude/settings.local.json`, a project-merged `.cursor/hooks.json` and its `.cursor/hooks//`, through `application/shared/remove-project-hooks.ts`. -5. A user-scope plugin directory (`~/.cursor/plugins/local/`) only once `realpath` proves it strictly inside the tool's declared user-scope directory (`domain/plugins/user-scope-containment.ts`). A `..` segment or a post-install symlink is left and named. +5. No user-scope plugin directory: the user manifest owns those files. Project clean detaches its canonical root only after local cleanup succeeds. A `..` segment or a post-install symlink is refused by explicit user-scope deletion. 6. Right after step 1, per tool driven: the cache root its profile declares (`NativeActivation.pluginCacheDir`), `/`, under the same containment. - A binary off `PATH` is named and left alone. - The shared `aidd-framework` registration is the one exception: `undoMarketplaceRegistration` refuses on the scope and warns, naming the host registration, the `marketplaces.json` entry and the tool's cache path. -- This project's refs are still uninstalled, except one a machine-global host (codex, copilot) enables while `references.json` names another project: left enabled and named. +- A native ref with an exact `@` claim in the user manifest is machine-owned: project clean never uninstalls it, including when this is the last project. It also leaves a project-labelled Codex/Copilot catalogue registered while an exact machine ref still claims that host catalogue; for non-framework catalogues, no user manifest to prove it unshared also leaves the registration for manual host cleanup. The framework source has its separate `references.json` authority and may not have a user manifest. Project clean detaches this project's root after local cleanup. A preexisting host ref without a machine claim is foreign and never adopted. +- Targeted user-scope `plugin update` rematerializes machine-owned files for Cursor. Copilot's native `plugin update @` updates an AIDD-owned exact ref through its binary, with dependent projects named and no manual host-cache writes. Codex has no targeted update verb; this command refuses for Codex rather than claiming `upgradeMarketplaces()` updated that one plugin. Use `marketplace refresh` or `framework update` for its catalogue/version workflow. - The warning names how many other projects still reference the source, or that `clean --scope user` purges it. - A dry-run reports the same list without dropping anything. +- Machine claims are changed under an inter-process manifest lock covering load, mutation and save. `plugin add` also holds it while fetching/building and writing the project; a slow operation may make another project wait up to 30 seconds, then fail with a named busy-lock error and require a retry. The lock is never stolen solely because it has aged, so contention cannot silently lose B's claim. ## Cache purge @@ -25,13 +27,13 @@ ## `clean --scope user` -- The one command that purges the shared source. +- The one command that purges the shared source and AIDD-owned user-scope plugin files/native refs, after every dependent project has detached. - The user manifest is optional: a project-scope `setup` never writes one yet leaves the whitelist behind. -- Without it, steps 1–3 are skipped and said so; other projects in `references.json` are named with the order to run `aidd clean` in each first. -- Steps 1–2 run at scope `"user"` always, never guessed from the host default. +- Without it, steps 1–3 are skipped and said so. A live project in `references.json` still blocks source/cache purge, even with `--force`. +- Steps 1–2 run at scope `"user"` only for native refs AIDD enabled and claimed; a foreign preexisting ref is never uninstalled. Active per-plugin dependents in the user manifest block removal, separately from the `references.json` source claim, even with `--force`. An absent binary, failed host unregister, unreadable source claim or unsafe tracked plugin path aborts without deleting the canonical manifest. - Step 3: `purgeAllNativeCaches`, shared with project-scope `clean`. - Step 4, always: a hardcoded whitelist under `userConfigDir()`: `cache/built/` in full, `cache/update-check.json`, root `update-check.json`, the `cache/` shell once a fresh `listDirectory` proves it empty, `references.json`; each re-resolved through `realpath` and `isStrictlyWithinUserScope` before deletion. - `manifest.json` goes through its repository, the `aidd-framework` entry alone out of `marketplaces.json` through the registry; neither takes a path from the manifest. - `userConfigDir()` itself is never a candidate. - Confirmation, unless `--force`, names the source, every version under `cache/built/`, every live project in `references.json`, and the no-registration note. -- A project pointed at the purged source repairs itself on its next `aidd sync`. +- A project must clean/detach before this purge; automatic repair of a still-dependent project is not a safety mechanism. diff --git a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md index f0597c8d1..be260e1e8 100644 --- a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md +++ b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md @@ -16,18 +16,18 @@ One registration of `aidd-framework` per machine, shared by every project. Read ## Migration -- `MarketplaceRegisterFrameworkUseCase` retires a project-scope entry to the shared one on every `setup` or `sync`. -- Unconditional, not behind `--force`: `MarketplaceRegistryAdapter.list()` answers project-scope entries first, so a leftover would win forever. -- The migration carries the entry's own recorded source, never the local-path default. -- It also repoints a host still tracking *another* project's pre-migration cache, without breaking that project, and records both claims. -- Codex and copilot have no readable marketplace registry; on a refusal at the reserved name and scope they reclaim it, `remove` then `add`. Never for an arbitrary marketplace. +- `MarketplaceRegisterFrameworkUseCase` normalizes a project-scope `aidd-framework` entry to the shared registry on `setup` or `sync`. This is local metadata, not authority to take over the native host. +- `MarketplaceRegistryAdapter.list()` answers project-scope entries first, so a leftover would otherwise shadow the shared entry. Normalization carries the entry's own recorded source, never the local-path default. +- A Claude host still tracking a pre-migration cache is repointed only if the old machine manifest, current host registry source, old catalogue/version, and complete host refs prove the exact AIDD cache with no foreign ref. It then records both projects' claims. +- Unproven legacy host state is left for manual reconciliation. Codex and Copilot do not reclaim a reserved name by force when the current source cannot be proved. +- Native hosts are reconciled only after their current marketplace source and complete plugin references prove AIDD ownership. A reserved-name collision without that proof is left for manual reconciliation; a host without a readable source refuses mutation. - This project's stale `.aidd/cache/built/aidd-framework/` is deleted only once the run reports no error, no missing binary, no failed build. A host needs that tree to resolve what it unregisters. ## `references.json` - `userConfigDir()/references.json`: `{ "": ["", …] }` (`contexts/framework/domain/ports/user-source-references.ts`). -- Written by `setup` and `sync` whenever the framework marketplace resolves to scope `"user"`. -- `clean` drops only this project's claim, once, never the registration. +- Written by `setup` and `sync` only after the selected native host registration is proven; a refusal does not create a new claim. +- `clean` drops only this project's claim, once. It proves current source and host references before any project-scoped host removal; a shared registration is left untouched even if its source cannot be read. A machine-global plugin ref without a canonical machine claim is left enabled for manual reconciliation, not treated as exclusively owned by the last project in `references.json`. - A help, not an authority: a `projectRoot` deleted with `rm -rf` is ignored at read. - At zero claims, `clean` names `clean --scope user` as the purge. - `aidd marketplace remove aidd-framework` refuses the same way; it carries no `--scope user` flag. @@ -42,12 +42,12 @@ One registration of `aidd-framework` per machine, shared by every project. Read ## `sync`'s write path - Refuses to write to a host already ahead. -- Brings a host behind forward as an ordinary update. +- Brings a host behind forward only after its current source and plugin refs prove AIDD ownership; unsupported Copilot versions refuse native activation. - Same version: no-op. ## `setup --scope user` -- Registers the shared source and drives native activation machine-wide. +- Registers the shared local source; native machine-wide activation requires verified current host source/ref proof. Unsupported Copilot versions refuse it. - Writes nothing under `projectRoot`: no content, no plugin prompt, no gitignore touch. - Its manifest: `userManifestPath(userConfigDir())`, `userConfigDir()/manifest.json`, same schema and version as the project one. - `UserManifestRepositoryAdapter` reuses `Manifest.fromJSON`/`toJSON`; its `delete()` removes that one file only. diff --git a/cli/aidd_docs/memory/internal/smoke-real.md b/cli/aidd_docs/memory/internal/smoke-real.md index af3b5d8e7..f6c12a2b9 100644 --- a/cli/aidd_docs/memory/internal/smoke-real.md +++ b/cli/aidd_docs/memory/internal/smoke-real.md @@ -28,7 +28,7 @@ Then each host's own inventory: `claude plugin details` lists the fixture's skills, hook events and MCP servers (never its agents: Claude counts none, measured 2026-09-09), `codex plugin list` marks it installed and enabled, `copilot plugin list` enabled. Cursor and opencode expose no inventory command. 3. `doctor`. 4. A host-side `claude plugin uninstall --scope local`, then the `sync --force` that repairs it. -5. Opencode's bridge. +5. Opencode's bridge, then a real `opencode run` under the repository's own `aidd-telemetry`: its run journal must hold a `session_start` from opencode. The bridge check alone passed while v5.10.0 journalled nothing (#812). 6. Two `marketplace add` guards: a different catalog under one name, an alias diverging from the catalog's. 7. `setup --scope user`: project clean per `git status --porcelain`, `userConfigDir()/manifest.json` appears, `doctor --scope user` healthy. 8. Two projects sharing one machine-scope marketplace. diff --git a/cli/aidd_docs/memory/testing.md b/cli/aidd_docs/memory/testing.md index f9b9a48d6..879e659f7 100644 --- a/cli/aidd_docs/memory/testing.md +++ b/cli/aidd_docs/memory/testing.md @@ -34,7 +34,9 @@ How this package is tested: the layers, the tools, the conventions. - `pnpm test` runs every project; `test:unit`, `test:integration`, `test:e2e`, `test:arch` select one. - `pnpm smoke` drives the real binary over the command matrix, hermetically. `pnpm smoke:full` adds the remote fetch. - `pnpm smoke:real` reaches real host registries: [`smoke-real.md`](internal/smoke-real.md). +- `pnpm smoke:collision` runs the real `codex` and `copilot` binaries in a throwaway HOME against a person's own install under the keys aidd uses, and checks `setup` and `clean` leave it as seeded. `smoke:real`'s unique names cannot meet that case. - `pnpm test:mutation:`; `mutation-scopes.json` declares each scope's globs (a leading `!` excludes) and the floor its score must hold. `tools` is split one scope per tool profile (`tools-claude`, `tools-codex`, …): a profile is a static declaration whose every mutant reruns each test that loads it, and the profiles together outlasted every other scope on a two-core runner. A weekly scheduled run replays every mutant with `--force`, so drift through a dependency an incremental run never replays is bounded to a week. `scripts/run-mutation.mjs` fails under the floor and keeps one incremental file per scope under `reports/mutation//`; `--force` reruns every mutant. Before a run the runner prunes the incremental file to kills alone: stryker reuses a result unless the mutant's file or a test that covered it changed, so a test written after the fact never reaches a mutant recorded as survived, uncovered or static, and a scope measured 74 read 67 in CI until it did. Raise a floor to the measured score after a run; never lower one without the reason in that file. +- `node scripts/run-mutation.mjs --changed []` mutates only the lines changed since `` (default `origin/next`) and names each survivor with its file and line. It holds no floor and writes no scope's incremental file, so it checks a branch before a push without moving any gate. - A unit or integration test reads the repository through `tests/helpers/repository-root.ts`, never by climbing `../` or `process.cwd()`: a mutation run copies `cli/` into a sandbox, where a relative climb lands nowhere (`tests-reach-the-repository-through-one-helper.arch.test.ts`). - Read counts live: a suite failing before producing a test contributes zero. diff --git a/cli/assets/configs/kilo/kilo.json b/cli/assets/configs/kilo/kilo.json new file mode 100644 index 000000000..571a15bec --- /dev/null +++ b/cli/assets/configs/kilo/kilo.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://app.kilo.ai/config.json" +} diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index 22611e29f..ccef764bf 100644 --- a/cli/mutation-scopes.json +++ b/cli/mutation-scopes.json @@ -1,9 +1,9 @@ { - "$comment": "The one declaration of what mutation testing covers and the floor each scope must hold. scripts/run-mutation.mjs runs a scope by name and fails below its break; tests/architecture/mutation-covers-source.arch.test.ts checks no source file falls outside both maps, that every scope declares a floor, and that package.json has a script per scope. Globs, never file lists: a list goes stale the day a file is added, and the score does not drop, because the mutants that would have died were never generated. A floor is raised to the measured score after a run, never lowered without the reason here. A scope's mutate is one glob or a list where a leading ! excludes; tools is split one scope per tool profile: a profile is a static declaration whose every mutant reruns each test that loads it, and the five together outlasted every other scope on a two-core runner; vscode's single file stays with the rest.", + "$comment": "The one declaration of what mutation testing covers and the floor each scope must hold. scripts/run-mutation.mjs runs a scope by name and fails below its break; tests/architecture/mutation-covers-source.arch.test.ts checks no source file falls outside both maps, that every scope declares a floor, and that package.json has a script per scope. Globs, never file lists: a list goes stale the day a file is added, and the score does not drop, because the mutants that would have died were never generated. A floor is raised to the measured score after a run, never lowered without the reason here. A scope's mutate is one glob or a list where a leading ! excludes; tools is split one scope per tool profile: a profile is a static declaration whose every mutant reruns each test that loads it, and the five together outlasted every other scope on a two-core runner; vscode's single file stays with the rest. canary mutates a fixture, not the product: its one mutant stops a module from loading, which Stryker's vitest runner scores as survived on its own; at 100 the scope fails the day that happens again, so a Stryker or vitest upgrade cannot quietly bypass tests/helpers/unloadable-file-as-failed-test.ts.", "scopes": { "kernel": { "mutate": "src/kernel/**/*.ts", - "break": 71 + "break": 95 }, "tools": { "mutate": [ @@ -12,13 +12,14 @@ "!src/contexts/tools/domain/profiles/codex/**/*.ts", "!src/contexts/tools/domain/profiles/copilot/**/*.ts", "!src/contexts/tools/domain/profiles/cursor/**/*.ts", + "!src/contexts/tools/domain/profiles/kilo/**/*.ts", "!src/contexts/tools/domain/profiles/opencode/**/*.ts" ], "break": 94 }, "telemetry": { "mutate": "src/contexts/telemetry/**/*.ts", - "break": 75 + "break": 93 }, "translate": { "mutate": "src/contexts/translate/**/*.ts", @@ -38,27 +39,38 @@ }, "runtime": { "mutate": "src/runtime/**/*.ts", - "break": 67 + "break": 90 }, "tools-claude": { "mutate": "src/contexts/tools/domain/profiles/claude/**/*.ts", - "break": 92 + "break": 94 }, "tools-codex": { "mutate": "src/contexts/tools/domain/profiles/codex/**/*.ts", - "break": 87 + "break": 94 }, "tools-copilot": { "mutate": "src/contexts/tools/domain/profiles/copilot/**/*.ts", - "break": 80 + "break": 95 }, "tools-cursor": { "mutate": "src/contexts/tools/domain/profiles/cursor/**/*.ts", - "break": 94 + "break": 97 }, "tools-opencode": { "mutate": "src/contexts/tools/domain/profiles/opencode/**/*.ts", - "break": 81 + "break": 94 + }, + "tools-kilo": { + "mutate": "src/contexts/tools/domain/profiles/kilo/**/*.ts", + "break": 64 + }, + "canary": { + "mutate": [ + "tests/fixtures/stryker-canary/**/*.ts", + "!tests/fixtures/stryker-canary/**/*.test.ts" + ], + "break": 100 } }, "excluded": { diff --git a/cli/package.json b/cli/package.json index c308ca7c4..0c9a4c796 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.3.4", - "bundleBudgetKB": 654, + "bundleBudgetKB": 734, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", @@ -45,13 +45,15 @@ "test:arch": "vitest run --project=architecture", "test:unit": "vitest run --project=unit", "test:integration": "vitest run --project=integration", - "test:e2e": "vitest run --project=e2e", + "test:e2e": "vitest run --project=e2e", + "test:e2e:kilo": "KILO_RUNTIME_SMOKE=1 vitest run --project=e2e tests/e2e/kilo-runtime.e2e.test.ts", "test:coverage": "vitest run --coverage", "test:kanban": "pnpm --dir ../kanban test", "test:watch": "vitest", "smoke": "pnpm build && bash scripts/smoke-tools.sh", "smoke:full": "pnpm build && SMOKE_REMOTE=1 bash scripts/smoke-tools.sh", "smoke:real": "pnpm build && bash scripts/smoke-real.sh", + "smoke:collision": "pnpm build && bash scripts/smoke-collision.sh", "typecheck": "tsc --noEmit", "lint": "biome check .", "format": "biome format --write .", @@ -65,6 +67,7 @@ "test:mutation:tools-codex": "node scripts/run-mutation.mjs tools-codex", "test:mutation:tools-copilot": "node scripts/run-mutation.mjs tools-copilot", "test:mutation:tools-cursor": "node scripts/run-mutation.mjs tools-cursor", + "test:mutation:tools-kilo": "node scripts/run-mutation.mjs tools-kilo", "test:mutation:tools-opencode": "node scripts/run-mutation.mjs tools-opencode", "test:mutation:telemetry": "node scripts/run-mutation.mjs telemetry", "test:mutation:translate": "node scripts/run-mutation.mjs translate", @@ -72,7 +75,7 @@ "test:mutation:framework": "node scripts/run-mutation.mjs framework", "test:mutation:presentation": "node scripts/run-mutation.mjs presentation", "test:mutation:runtime": "node scripts/run-mutation.mjs runtime", - "prepare": "lefthook install", + "test:mutation:canary": "node scripts/run-mutation.mjs canary", "knip": "knip" }, "dependencies": { @@ -94,7 +97,6 @@ "fast-check": "^4.7.0", "jscpd": "^5.0.0", "knip": "^6.0.0", - "lefthook": "^2.1.10", "tsup": "^8.0.0", "typescript": "^7.0.2", "vitest": "^3.2.6" diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml index 7c5fa503c..4fb58a18c 100644 --- a/cli/pnpm-lock.yaml +++ b/cli/pnpm-lock.yaml @@ -106,7 +106,7 @@ settings: excludeLinksFromLockfile: false overrides: - fast-uri: '>=3.1.2' + fast-uri: '>=3.1.6 <4' picomatch: '>=4.0.4' postcss: '>=8.5.10' qs: '>=6.15.2' @@ -117,7 +117,7 @@ importers: dependencies: '@inquirer/prompts': specifier: ^8.5.2 - version: 8.7.0(@types/node@26.4.0) + version: 8.7.2(@types/node@26.4.0) ajv: specifier: ^8.20.0 version: 8.20.0 @@ -129,7 +129,7 @@ importers: version: 15.0.0 simple-git: specifier: ^3.36.0 - version: 3.36.0 + version: 3.36.0(supports-color@7.2.0) smol-toml: specifier: ^1.6.1 version: 1.8.0 @@ -145,16 +145,16 @@ importers: version: 21.2.2 '@stryker-mutator/core': specifier: ^9.6.1 - version: 9.6.1(@types/node@26.4.0) + version: 9.6.1(@types/node@26.4.0)(supports-color@7.2.0) '@stryker-mutator/vitest-runner': specifier: ^9.6.1 - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.4.0))(vitest@3.2.6(@types/node@26.4.0)) + version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.4.0)(supports-color@7.2.0))(vitest@3.2.6(@types/node@26.4.0)(supports-color@7.2.0)) '@types/node': specifier: ^26.1.1 version: 26.4.0 '@vitest/coverage-v8': specifier: ^3.2.6 - version: 3.2.6(vitest@3.2.6(@types/node@26.4.0)) + version: 3.2.6(supports-color@7.2.0)(vitest@3.2.6(@types/node@26.4.0)(supports-color@7.2.0)) fast-check: specifier: ^4.7.0 version: 4.9.0 @@ -164,18 +164,15 @@ importers: knip: specifier: ^6.0.0 version: 6.33.0 - lefthook: - specifier: ^2.1.10 - version: 2.1.12 tsup: specifier: ^8.0.0 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@7.0.2)(yaml@2.9.0) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(supports-color@7.2.0)(typescript@7.0.2)(yaml@2.9.0) typescript: specifier: ^7.0.2 version: 7.0.2 vitest: specifier: ^3.2.6 - version: 3.2.6(@types/node@26.4.0) + version: 3.2.6(@types/node@26.4.0)(supports-color@7.2.0) packages: @@ -789,12 +786,12 @@ packages: cpu: [x64] os: [win32] - '@inquirer/ansi@2.0.7': - resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + '@inquirer/ansi@2.0.8': + resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/checkbox@5.2.3': - resolution: {integrity: sha512-XEYX2WA8SBkLPczL6/yXPHLPCvDoptmh9v56Cy05BSV1Smk1vWy19bTC4qJBuIffw7+6l4CcaYYzGqG60RfW1g==} + '@inquirer/checkbox@5.2.5': + resolution: {integrity: sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -802,8 +799,8 @@ packages: '@types/node': optional: true - '@inquirer/confirm@6.3.0': - resolution: {integrity: sha512-pZHXJImFtERmSNMBHcjwuz8Ck5vEFEYNUZnwbb8aJpjHv/TwGuFErNxF2Hp8+V+pNJs2EYPMlyWscvFEqO9jOQ==} + '@inquirer/confirm@6.3.2': + resolution: {integrity: sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -811,8 +808,8 @@ packages: '@types/node': optional: true - '@inquirer/core@12.0.1': - resolution: {integrity: sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==} + '@inquirer/core@12.0.3': + resolution: {integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -820,8 +817,8 @@ packages: '@types/node': optional: true - '@inquirer/editor@5.3.1': - resolution: {integrity: sha512-y43COoyVUjPWIobn2Qep/uI1drPS78aaZZZ9kVi94Tyu/GuW2N8d8Q4rifJXGAXCEAXCPTTMjD8gC1HyvM5ukA==} + '@inquirer/editor@5.3.3': + resolution: {integrity: sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -829,8 +826,8 @@ packages: '@types/node': optional: true - '@inquirer/expand@5.1.3': - resolution: {integrity: sha512-3NQJiXNJ/aj9wiAsr7pECdp5Qe9J0X9YUJCKsaFXS+ddOxfL6J4AIl3w3T4Gq3kK0WsQY5GMoDokK5X94m6lHw==} + '@inquirer/expand@5.1.5': + resolution: {integrity: sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -838,8 +835,8 @@ packages: '@types/node': optional: true - '@inquirer/external-editor@3.0.4': - resolution: {integrity: sha512-tZbbaK2ovq6vlrRBNQvjrypmrED/p5x2ncIHQ79cD55tei3dD96v5glMMA+6tiq7K104i/25DVYKWVPJuV6ptA==} + '@inquirer/external-editor@3.0.5': + resolution: {integrity: sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -847,12 +844,12 @@ packages: '@types/node': optional: true - '@inquirer/figures@2.0.8': - resolution: {integrity: sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==} + '@inquirer/figures@2.0.9': + resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/input@5.1.4': - resolution: {integrity: sha512-3xQkQrOvgOzpSN2ciTVdRDlg1FWMCA8l+0KfB6SNlILoTCGzJTzO/gc0Rwjcb3usuGyKdaGtI6OiyMdeMeLWkg==} + '@inquirer/input@5.1.6': + resolution: {integrity: sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -860,8 +857,8 @@ packages: '@types/node': optional: true - '@inquirer/number@4.2.1': - resolution: {integrity: sha512-5KaqwZNLRpUuWcoCrYghPP9TMaXL5v2Sk4xqePM7RCVegcJStoXdWibio60YIC1bec+z1fCyb67N6XPJIkZtGA==} + '@inquirer/number@4.2.3': + resolution: {integrity: sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -869,8 +866,8 @@ packages: '@types/node': optional: true - '@inquirer/password@5.2.0': - resolution: {integrity: sha512-CvVcW09emkBESEOW+4R8CjLNkP3fB3XrjeL8CDvfpjgrJN+V9oerXmJAXXM3l+4xqYPD5Yaujzy/Ph0PLOdDuA==} + '@inquirer/password@5.2.2': + resolution: {integrity: sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -878,8 +875,8 @@ packages: '@types/node': optional: true - '@inquirer/prompts@8.7.0': - resolution: {integrity: sha512-yQwBMYvpJ6jqrXtKiOwRD5XezjJoyt3VQvIyjsr5Arqb519nfIohOQymWVJ8/vEgg8xtZerrCsqlSaqt/LPC9A==} + '@inquirer/prompts@8.7.2': + resolution: {integrity: sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -887,8 +884,8 @@ packages: '@types/node': optional: true - '@inquirer/rawlist@5.3.3': - resolution: {integrity: sha512-Mu7WrtmDLaXBDEyrRLS70SZgX9ZSm4Up1w0ZxiH8C1OOp9oaVCn2k8q3QGgmlnhsKYUhuaU3zFWhAP6wxkVIMA==} + '@inquirer/rawlist@5.3.5': + resolution: {integrity: sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -896,8 +893,8 @@ packages: '@types/node': optional: true - '@inquirer/search@4.3.1': - resolution: {integrity: sha512-0VWOvsHWI0rPj6CG70MoP4oXNCB6adcyN8bVFZXnh11eLDdPIK2f2XCmva78acPPDfJisfab7qNakwBh7hdBXw==} + '@inquirer/search@4.3.3': + resolution: {integrity: sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -905,8 +902,8 @@ packages: '@types/node': optional: true - '@inquirer/select@5.2.3': - resolution: {integrity: sha512-KuRTodDa6xBXX2noIpjuitpX/QT7Sfav7dIZ/OfUY54Hxg95nrGoshSzxx6Ey7qqLbImKdiGkSDt7KjPXjQgmA==} + '@inquirer/select@5.2.5': + resolution: {integrity: sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -914,8 +911,8 @@ packages: '@types/node': optional: true - '@inquirer/type@4.1.0': - resolution: {integrity: sha512-FMiJpuHUG3Dk0ex+UIXkre7i+i4OcwHWk9YdcVtZHFwb/r2rnrU2ipTCNAB7A+QOP0ryzIcqOfy76fRyyvOEAw==} + '@inquirer/type@4.1.1': + resolution: {integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1819,8 +1816,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@4.0.0: - resolution: {integrity: sha512-l90y339r2DkZs/ldcWQXcwTjkbp/NbuJDGYoQ3awBgaT3GXOFkm3OkVpz6Z86TywYcya0eVP2r1kTV90f3krGQ==} + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -2063,60 +2060,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - lefthook-darwin-arm64@2.1.12: - resolution: {integrity: sha512-GSUjqaCuxAYlPOovbjXEWE3HAIa/xOQkTTmZ937zieQldjPLTaC7+s5FHoxKywn+D9riBlofkDqG7Z4f5zzmPA==} - cpu: [arm64] - os: [darwin] - - lefthook-darwin-x64@2.1.12: - resolution: {integrity: sha512-qAUHSSw/7Afi5wxkoLkxORxSicCeTBXyVLnRgD6YZra6udviozpK3Aok9Z6FIWeKc5FBijRMSNDxGPTREhsjcQ==} - cpu: [x64] - os: [darwin] - - lefthook-freebsd-arm64@2.1.12: - resolution: {integrity: sha512-FHZRfKliFNbyz54zsoGdVe9muq67cNjBTZ6jrPPUjI7xUuyCwSpzA+1OpiwJT00L9pP5ZGONBqnGZKnrpC+7Uw==} - cpu: [arm64] - os: [freebsd] - - lefthook-freebsd-x64@2.1.12: - resolution: {integrity: sha512-HYQZPGy2DDEx7dbuotusJpujY5q9igOLgx36qeeQcNQuvvdGHPj2ZGSIsGKhoJ0dw5Qa4knKJoPAHEZQWdV/og==} - cpu: [x64] - os: [freebsd] - - lefthook-linux-arm64@2.1.12: - resolution: {integrity: sha512-ipjOri2PB/sk4noPrHQuM5fcpNBjmlKo4qMtXyLnxeOxGYHWZzf0Xi0OtrXHQ//tOprcv40ADvhUuSdcGqigLw==} - cpu: [arm64] - os: [linux] - - lefthook-linux-x64@2.1.12: - resolution: {integrity: sha512-DmU6xfvqVoFdW+STyc70YI4Ry8vkHGKHx+IJ1Js/Gacq5dv+fiwNzwle3bi28EkL6P8xY67B/CfQfRj4SRU4tg==} - cpu: [x64] - os: [linux] - - lefthook-openbsd-arm64@2.1.12: - resolution: {integrity: sha512-vb0J7bElLvoaCWQmna3HntsvoNqMsGAhfjjC99ss1OdCteufZKM3RxCVoMBEbD50Itv2c1Ua2AFVToltVFTy1Q==} - cpu: [arm64] - os: [openbsd] - - lefthook-openbsd-x64@2.1.12: - resolution: {integrity: sha512-QOmhDWqPPK4+99scanfEmbJjUR+JYC93qhr/0bp5Dj3LaR3kVFNWc6zOQUsOTPy/LC6PG759Lej/mr/BtjUL2Q==} - cpu: [x64] - os: [openbsd] - - lefthook-windows-arm64@2.1.12: - resolution: {integrity: sha512-eErEyr9AHkRFj6TxpCQeKGd0s6IrtL/VEIZ/ssg8dNfG+ycR4jAW/IAlrJSw6/ZQXb3WtWLaQ6QA5c+TLh7vmg==} - cpu: [arm64] - os: [win32] - - lefthook-windows-x64@2.1.12: - resolution: {integrity: sha512-0h+WmDVdDriTjloD/UbjPq/ZtqaaJlPAdGcVwMCEdAxfbF0FTgDbKX3igui9g2U/qG2y6QpVhtz1jeiRe7ctaw==} - cpu: [x64] - os: [win32] - - lefthook@2.1.12: - resolution: {integrity: sha512-2uOexfsrrRhCiTUWdGyDySxVHHhOFJ8MQejs27dM3hugrQB48/z1ZVlaNoxpZ1SnzQ1GaDIbO5rAvicwyiknYQ==} - hasBin: true - lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2705,20 +2648,20 @@ snapshots: '@babel/compat-data@7.29.3': {} - '@babel/core@7.29.0': + '@babel/core@7.29.0(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helpers': 7.29.2 '@babel/parser': 7.29.0 '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -2745,41 +2688,41 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0) '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0) + '@babel/traverse': 7.29.0(supports-color@7.2.0) semver: 6.3.1 transitivePeerDependencies: - supports-color '@babel/helper-globals@7.28.0': {} - '@babel/helper-member-expression-to-functions@7.28.5': + '@babel/helper-member-expression-to-functions@7.28.5(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.28.6(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/helper-module-imports': 7.28.6(supports-color@7.2.0) '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -2789,18 +2732,18 @@ snapshots: '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0) '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + '@babel/helper-skip-transparent-expression-wrappers@7.27.1(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -2822,73 +2765,73 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0)) transitivePeerDependencies: - supports-color - '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0)) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -2898,7 +2841,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.0(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 @@ -2906,7 +2849,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -3234,29 +3177,29 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@inquirer/ansi@2.0.7': {} + '@inquirer/ansi@2.0.8': {} - '@inquirer/checkbox@5.2.3(@types/node@26.4.0)': + '@inquirer/checkbox@5.2.5(@types/node@26.4.0)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 12.0.1(@types/node@26.4.0) - '@inquirer/figures': 2.0.8 - '@inquirer/type': 4.1.0(@types/node@26.4.0) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.3(@types/node@26.4.0) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@26.4.0) optionalDependencies: '@types/node': 26.4.0 - '@inquirer/confirm@6.3.0(@types/node@26.4.0)': + '@inquirer/confirm@6.3.2(@types/node@26.4.0)': dependencies: - '@inquirer/core': 12.0.1(@types/node@26.4.0) - '@inquirer/type': 4.1.0(@types/node@26.4.0) + '@inquirer/core': 12.0.3(@types/node@26.4.0) + '@inquirer/type': 4.1.1(@types/node@26.4.0) optionalDependencies: '@types/node': 26.4.0 - '@inquirer/core@12.0.1(@types/node@26.4.0)': + '@inquirer/core@12.0.3(@types/node@26.4.0)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/figures': 2.0.8 - '@inquirer/type': 4.1.0(@types/node@26.4.0) + '@inquirer/ansi': 2.0.8 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@26.4.0) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 @@ -3264,92 +3207,92 @@ snapshots: optionalDependencies: '@types/node': 26.4.0 - '@inquirer/editor@5.3.1(@types/node@26.4.0)': + '@inquirer/editor@5.3.3(@types/node@26.4.0)': dependencies: - '@inquirer/core': 12.0.1(@types/node@26.4.0) - '@inquirer/external-editor': 3.0.4(@types/node@26.4.0) - '@inquirer/type': 4.1.0(@types/node@26.4.0) + '@inquirer/core': 12.0.3(@types/node@26.4.0) + '@inquirer/external-editor': 3.0.5(@types/node@26.4.0) + '@inquirer/type': 4.1.1(@types/node@26.4.0) optionalDependencies: '@types/node': 26.4.0 - '@inquirer/expand@5.1.3(@types/node@26.4.0)': + '@inquirer/expand@5.1.5(@types/node@26.4.0)': dependencies: - '@inquirer/core': 12.0.1(@types/node@26.4.0) - '@inquirer/type': 4.1.0(@types/node@26.4.0) + '@inquirer/core': 12.0.3(@types/node@26.4.0) + '@inquirer/type': 4.1.1(@types/node@26.4.0) optionalDependencies: '@types/node': 26.4.0 - '@inquirer/external-editor@3.0.4(@types/node@26.4.0)': + '@inquirer/external-editor@3.0.5(@types/node@26.4.0)': dependencies: chardet: 2.2.0 iconv-lite: 0.7.3 optionalDependencies: '@types/node': 26.4.0 - '@inquirer/figures@2.0.8': {} + '@inquirer/figures@2.0.9': {} - '@inquirer/input@5.1.4(@types/node@26.4.0)': + '@inquirer/input@5.1.6(@types/node@26.4.0)': dependencies: - '@inquirer/core': 12.0.1(@types/node@26.4.0) - '@inquirer/type': 4.1.0(@types/node@26.4.0) + '@inquirer/core': 12.0.3(@types/node@26.4.0) + '@inquirer/type': 4.1.1(@types/node@26.4.0) optionalDependencies: '@types/node': 26.4.0 - '@inquirer/number@4.2.1(@types/node@26.4.0)': + '@inquirer/number@4.2.3(@types/node@26.4.0)': dependencies: - '@inquirer/core': 12.0.1(@types/node@26.4.0) - '@inquirer/type': 4.1.0(@types/node@26.4.0) + '@inquirer/core': 12.0.3(@types/node@26.4.0) + '@inquirer/type': 4.1.1(@types/node@26.4.0) optionalDependencies: '@types/node': 26.4.0 - '@inquirer/password@5.2.0(@types/node@26.4.0)': + '@inquirer/password@5.2.2(@types/node@26.4.0)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 12.0.1(@types/node@26.4.0) - '@inquirer/type': 4.1.0(@types/node@26.4.0) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.3(@types/node@26.4.0) + '@inquirer/type': 4.1.1(@types/node@26.4.0) optionalDependencies: '@types/node': 26.4.0 - '@inquirer/prompts@8.7.0(@types/node@26.4.0)': - dependencies: - '@inquirer/checkbox': 5.2.3(@types/node@26.4.0) - '@inquirer/confirm': 6.3.0(@types/node@26.4.0) - '@inquirer/editor': 5.3.1(@types/node@26.4.0) - '@inquirer/expand': 5.1.3(@types/node@26.4.0) - '@inquirer/input': 5.1.4(@types/node@26.4.0) - '@inquirer/number': 4.2.1(@types/node@26.4.0) - '@inquirer/password': 5.2.0(@types/node@26.4.0) - '@inquirer/rawlist': 5.3.3(@types/node@26.4.0) - '@inquirer/search': 4.3.1(@types/node@26.4.0) - '@inquirer/select': 5.2.3(@types/node@26.4.0) + '@inquirer/prompts@8.7.2(@types/node@26.4.0)': + dependencies: + '@inquirer/checkbox': 5.2.5(@types/node@26.4.0) + '@inquirer/confirm': 6.3.2(@types/node@26.4.0) + '@inquirer/editor': 5.3.3(@types/node@26.4.0) + '@inquirer/expand': 5.1.5(@types/node@26.4.0) + '@inquirer/input': 5.1.6(@types/node@26.4.0) + '@inquirer/number': 4.2.3(@types/node@26.4.0) + '@inquirer/password': 5.2.2(@types/node@26.4.0) + '@inquirer/rawlist': 5.3.5(@types/node@26.4.0) + '@inquirer/search': 4.3.3(@types/node@26.4.0) + '@inquirer/select': 5.2.5(@types/node@26.4.0) optionalDependencies: '@types/node': 26.4.0 - '@inquirer/rawlist@5.3.3(@types/node@26.4.0)': + '@inquirer/rawlist@5.3.5(@types/node@26.4.0)': dependencies: - '@inquirer/core': 12.0.1(@types/node@26.4.0) - '@inquirer/type': 4.1.0(@types/node@26.4.0) + '@inquirer/core': 12.0.3(@types/node@26.4.0) + '@inquirer/type': 4.1.1(@types/node@26.4.0) optionalDependencies: '@types/node': 26.4.0 - '@inquirer/search@4.3.1(@types/node@26.4.0)': + '@inquirer/search@4.3.3(@types/node@26.4.0)': dependencies: - '@inquirer/core': 12.0.1(@types/node@26.4.0) - '@inquirer/figures': 2.0.8 - '@inquirer/type': 4.1.0(@types/node@26.4.0) + '@inquirer/core': 12.0.3(@types/node@26.4.0) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@26.4.0) optionalDependencies: '@types/node': 26.4.0 - '@inquirer/select@5.2.3(@types/node@26.4.0)': + '@inquirer/select@5.2.5(@types/node@26.4.0)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 12.0.1(@types/node@26.4.0) - '@inquirer/figures': 2.0.8 - '@inquirer/type': 4.1.0(@types/node@26.4.0) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.3(@types/node@26.4.0) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@26.4.0) optionalDependencies: '@types/node': 26.4.0 - '@inquirer/type@4.1.0(@types/node@26.4.0)': + '@inquirer/type@4.1.1(@types/node@26.4.0)': optionalDependencies: '@types/node': 26.4.0 @@ -3383,9 +3326,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@kwsites/file-exists@1.1.1': + '@kwsites/file-exists@1.1.1(supports-color@7.2.0)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -3619,11 +3562,11 @@ snapshots: tslib: 2.8.1 typed-inject: 5.0.0 - '@stryker-mutator/core@9.6.1(@types/node@26.4.0)': + '@stryker-mutator/core@9.6.1(@types/node@26.4.0)(supports-color@7.2.0)': dependencies: - '@inquirer/prompts': 8.7.0(@types/node@26.4.0) + '@inquirer/prompts': 8.7.2(@types/node@26.4.0) '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/instrumenter': 9.6.1 + '@stryker-mutator/instrumenter': 9.6.1(supports-color@7.2.0) '@stryker-mutator/util': 9.6.1 ajv: 8.18.0 chalk: 5.6.2 @@ -3651,14 +3594,14 @@ snapshots: - '@types/node' - supports-color - '@stryker-mutator/instrumenter@9.6.1': + '@stryker-mutator/instrumenter@9.6.1(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/generator': 7.29.1 '@babel/parser': 7.29.0 - '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) '@stryker-mutator/api': 9.6.1 '@stryker-mutator/util': 9.6.1 angular-html-parser: 10.4.0 @@ -3670,14 +3613,14 @@ snapshots: '@stryker-mutator/util@9.6.1': {} - '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.4.0))(vitest@3.2.6(@types/node@26.4.0))': + '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.4.0)(supports-color@7.2.0))(vitest@3.2.6(@types/node@26.4.0)(supports-color@7.2.0))': dependencies: '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/core': 9.6.1(@types/node@26.4.0) + '@stryker-mutator/core': 9.6.1(@types/node@26.4.0)(supports-color@7.2.0) '@stryker-mutator/util': 9.6.1 semver: 7.7.4 tslib: 2.8.1 - vitest: 3.2.6(@types/node@26.4.0) + vitest: 3.2.6(@types/node@26.4.0)(supports-color@7.2.0) '@tybys/wasm-util@0.10.3': dependencies: @@ -3757,22 +3700,22 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@26.4.0))': + '@vitest/coverage-v8@3.2.6(supports-color@7.2.0)(vitest@3.2.6(@types/node@26.4.0)(supports-color@7.2.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 ast-v8-to-istanbul: 0.3.12 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 + istanbul-lib-source-maps: 5.0.6(supports-color@7.2.0) istanbul-reports: 3.2.0 magic-string: 0.30.21 magicast: 0.3.5 std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.6(@types/node@26.4.0) + vitest: 3.2.6(@types/node@26.4.0)(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -3827,14 +3770,14 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 4.0.0 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 4.0.0 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -3991,9 +3934,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 deep-eql@5.0.2: {} @@ -4128,7 +4073,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@4.0.0: {} + fast-uri@3.1.7: {} fast-wrap-ansi@0.2.2: dependencies: @@ -4260,10 +4205,10 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: + istanbul-lib-source-maps@5.0.6(supports-color@7.2.0): dependencies: '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -4350,49 +4295,6 @@ snapshots: yaml: 2.9.0 zod: 4.5.4 - lefthook-darwin-arm64@2.1.12: - optional: true - - lefthook-darwin-x64@2.1.12: - optional: true - - lefthook-freebsd-arm64@2.1.12: - optional: true - - lefthook-freebsd-x64@2.1.12: - optional: true - - lefthook-linux-arm64@2.1.12: - optional: true - - lefthook-linux-x64@2.1.12: - optional: true - - lefthook-openbsd-arm64@2.1.12: - optional: true - - lefthook-openbsd-x64@2.1.12: - optional: true - - lefthook-windows-arm64@2.1.12: - optional: true - - lefthook-windows-x64@2.1.12: - optional: true - - lefthook@2.1.12: - optionalDependencies: - lefthook-darwin-arm64: 2.1.12 - lefthook-darwin-x64: 2.1.12 - lefthook-freebsd-arm64: 2.1.12 - lefthook-freebsd-x64: 2.1.12 - lefthook-linux-arm64: 2.1.12 - lefthook-linux-x64: 2.1.12 - lefthook-openbsd-arm64: 2.1.12 - lefthook-openbsd-x64: 2.1.12 - lefthook-windows-arm64: 2.1.12 - lefthook-windows-x64: 2.1.12 - lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -4686,13 +4588,13 @@ snapshots: signal-exit@4.1.0: {} - simple-git@3.36.0: + simple-git@3.36.0(supports-color@7.2.0): dependencies: - '@kwsites/file-exists': 1.1.1 + '@kwsites/file-exists': 1.1.1(supports-color@7.2.0) '@kwsites/promise-deferred': 1.1.1 '@simple-git/args-pathspec': 1.0.3 '@simple-git/argv-parser': 1.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4801,13 +4703,13 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@7.0.2)(yaml@2.9.0): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(supports-color@7.2.0)(typescript@7.0.2)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) esbuild: 0.27.3 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -4880,10 +4782,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - vite-node@3.2.4(@types/node@26.4.0): + vite-node@3.2.4(@types/node@26.4.0)(supports-color@7.2.0): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 5.4.21(@types/node@26.4.0) @@ -4907,7 +4809,7 @@ snapshots: '@types/node': 26.4.0 fsevents: 2.3.3 - vitest@3.2.6(@types/node@26.4.0): + vitest@3.2.6(@types/node@26.4.0)(supports-color@7.2.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 @@ -4918,7 +4820,7 @@ snapshots: '@vitest/spy': 3.2.6 '@vitest/utils': 3.2.6 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -4930,7 +4832,7 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 5.4.21(@types/node@26.4.0) - vite-node: 3.2.4(@types/node@26.4.0) + vite-node: 3.2.4(@types/node@26.4.0)(supports-color@7.2.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.4.0 diff --git a/cli/pnpm-workspace.yaml b/cli/pnpm-workspace.yaml index b03326216..d59cf5c71 100644 --- a/cli/pnpm-workspace.yaml +++ b/cli/pnpm-workspace.yaml @@ -11,7 +11,7 @@ packages: [] # security floors were silently ignored while the lockfile still recorded them, which is # the ERR_PNPM_LOCKFILE_CONFIG_MISMATCH every `cli` install answered with. overrides: - fast-uri: '>=3.1.2' + fast-uri: '>=3.1.6 <4' picomatch: '>=4.0.4' postcss: '>=8.5.10' qs: '>=6.15.2' diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index 69a7864aa..c99bfadca 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -6,7 +6,7 @@ const root = resolve(fileURLToPath(import.meta.url), "../.."); const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // The budget makes growth visible rather than walling it off: a raise is deliberate, is -// what a reviewer sees, and leaves ~2 % headroom over what was measured, never more. +// what a reviewer sees, and leaves at most ~2 % headroom over what was measured. // The registry of every raise, budget then measurement then what landed: // 560 KB: 500.8 KB, measurement across five tools. // 590 KB: 567.7 KB, one person resolved across tools and machines. @@ -22,6 +22,14 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // 625 KB: 612.56 KB, `--scope user` on `setup`, `doctor` and `sync`. // 641 KB: 628.26 KB, `clean --scope user` and sync's migration of a pre-shared-source project. // 654 KB: 641.0 KB, the shared-plugin, narrowing, hook and Windows-lookup passes. +// 682 KB: 675.5 KB, canonical per-plugin machine claims, scoped user operations and +// inter-process ownership guards for Cursor, Codex and Copilot (+31.3 KB over next's 644.2). +// 710 KB: 703.8 KB, current native host-source proof and exact hook/MCP/user-file provenance +// guards for #829 (+22.0 KB over the first 829 candidate's 681.8); 0.9% measured headroom. +// 722 KB: 714.2 KB, exact native-claim partition, multihost preflight, and symlink boundary +// guards for #829 (+10.4 KB over the preceding 703.8 KB); 1.1% measured headroom. +// 734 KB: 725.8 KB, Kilo Code's profile, generated bridge, and runtime smoke support (+9.6 KB +// over next's 716.2 KB); 1.1% measured headroom. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/scripts/run-mutation.d.mts b/cli/scripts/run-mutation.d.mts index 36b9d5278..3aea3128d 100644 --- a/cli/scripts/run-mutation.d.mts +++ b/cli/scripts/run-mutation.d.mts @@ -7,7 +7,14 @@ export interface MutationReport { readonly files?: Readonly< Record< string, - { readonly mutants: readonly { readonly status: string; readonly static?: boolean }[] } + { + readonly mutants: readonly { + readonly status: string; + readonly static?: boolean; + readonly mutatorName?: string; + readonly location?: { readonly start: { readonly line: number } }; + }[]; + } > >; } @@ -21,3 +28,6 @@ export function strykerArgs( ): string[]; export function scoreOf(report: MutationReport): number; export function breakVerdict(score: number, declared: MutationScope): string | null; +export function changedRanges(diff: string): string[]; +export function changedArgs(ranges: readonly string[]): string[]; +export function survivorsOf(report: MutationReport): string[]; diff --git a/cli/scripts/run-mutation.mjs b/cli/scripts/run-mutation.mjs index 3e178e051..daa554615 100644 --- a/cli/scripts/run-mutation.mjs +++ b/cli/scripts/run-mutation.mjs @@ -56,14 +56,53 @@ export function pruneIncremental(report) { for (const [name, file] of Object.entries(report.files ?? {})) { files[name] = { ...file, - mutants: file.mutants.filter( - (mutant) => !mutant.static && (mutant.status === "Killed" || mutant.status === "Timeout") - ), + mutants: file.mutants.filter((mutant) => !mutant.static && mutant.status === "Killed"), }; } return { ...report, files }; } +const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/; + +/** The lines a `git diff -U0` adds or changes under `src/`, as stryker `file:start-end` ranges. + * A pure deletion adds no line to mutate, and a file outside `src/` belongs to no scope. */ +export function changedRanges(diff) { + const ranges = []; + let file = null; + for (const line of diff.split("\n")) { + if (line.startsWith("+++ ")) { + const target = line.slice(4); + file = target.startsWith("b/") ? target.slice(2) : null; + continue; + } + const hunk = HUNK.exec(line); + if (hunk === null || file === null || !file.startsWith("src/") || !file.endsWith(".ts")) + continue; + const start = Number(hunk[1]); + const count = hunk[2] === undefined ? 1 : Number(hunk[2]); + if (count > 0) ranges.push(`${file}:${start}-${start + count - 1}`); + } + return ranges; +} + +/** No scope, no incremental file and no floor: a check on a branch must never move a gate. */ +export function changedArgs(ranges) { + return ["run", "--mutate", ranges.join(",")]; +} + +export function survivorsOf(report) { + const survivors = []; + for (const [name, file] of Object.entries(report.files ?? {})) { + for (const mutant of file.mutants) { + if (mutant.status !== "Survived" && mutant.status !== "NoCoverage") continue; + survivors.push( + `${name}:${mutant.location.start.line} ${mutant.mutatorName} (${mutant.status})` + ); + } + } + return survivors; +} + /** Below the declared floor is a failure the run itself raises; stryker's own `thresholds` * would need a config file per scope to say the same thing. */ export function breakVerdict(score, declared) { @@ -86,14 +125,56 @@ function pruneIncrementalFile(path) { } function usage(problem, scopes) { - console.error(`${problem}\n\nUsage: node scripts/run-mutation.mjs [--force]`); + console.error( + `${problem}\n\nUsage: node scripts/run-mutation.mjs [--force]\n node scripts/run-mutation.mjs --changed []` + ); console.error(`Scopes: ${Object.keys(scopes).join(", ")}`); process.exit(1); } +function git(args) { + const result = spawnSync("git", args, { cwd: CLI_ROOT, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); + return result.stdout.trim(); +} + +function fileReports(dir) { + for (const name of WRITTEN_REPORTS) { + const written = join(REPORT_ROOT, name); + if (existsSync(written)) renameSync(written, join(dir, name)); + } +} + +function mainChanged(base = "origin/next") { + const mergeBase = git(["merge-base", base, "HEAD"]); + const ranges = changedRanges(git(["diff", "-U0", "--relative", mergeBase, "--", "src"])); + if (ranges.length === 0) { + console.log(`No line under src/ changed since ${base}: nothing to mutate.`); + return; + } + const dir = join(REPORT_ROOT, "changed"); + mkdirSync(dir, { recursive: true }); + const result = spawnSync(join(CLI_ROOT, "node_modules", ".bin", "stryker"), changedArgs(ranges), { + cwd: CLI_ROOT, + stdio: "inherit", + }); + rmSync(join(CLI_ROOT, ".stryker-tmp"), { recursive: true, force: true }); + fileReports(dir); + if (result.status !== 0) process.exit(result.status ?? 1); + const report = JSON.parse(readFileSync(join(dir, "mutation.json"), "utf8")); + const mutants = Object.values(report.files ?? {}).flatMap((file) => file.mutants).length; + const measured = + mutants === 0 ? "no mutant on those lines" : `score ${scoreOf(report).toFixed(1)}`; + console.log( + `\nReport: reports/mutation/changed/ (${measured}, ${ranges.length} changed range(s) since ${base})` + ); + for (const survivor of survivorsOf(report)) console.log(` survived: ${survivor}`); +} + function main() { const scopes = loadScopes(); const [scope, ...flags] = process.argv.slice(2); + if (scope === "--changed") return mainChanged(flags[0]); if (scope === undefined) usage("No scope given.", scopes); if (!Object.hasOwn(scopes, scope)) usage(`Unknown scope "${scope}".`, scopes); const force = flags.includes("--force"); @@ -111,10 +192,7 @@ function main() { // A sandbox survives an interrupted run and they grow to hundreds of megabytes. rmSync(join(CLI_ROOT, ".stryker-tmp"), { recursive: true, force: true }); - for (const name of WRITTEN_REPORTS) { - const written = join(REPORT_ROOT, name); - if (existsSync(written)) renameSync(written, join(scopeDir, name)); - } + fileReports(scopeDir); if (result.status !== 0) process.exit(result.status ?? 1); diff --git a/cli/scripts/smoke-collision.sh b/cli/scripts/smoke-collision.sh new file mode 100755 index 000000000..4ff431807 --- /dev/null +++ b/cli/scripts/smoke-collision.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Real host binaries against a person's own install under the keys aidd uses: +# `@aidd-framework` for codex and copilot, the plugin name alone for cursor. +# `smoke-real.sh` gives every run a unique name so it never meets a real install, which is +# exactly why it cannot see this collision. Everything here runs in a throwaway HOME (and +# CODEX_HOME); the person's install is seeded there, never read from the real one. +# Never in CI, never in lefthook: `pnpm smoke:collision`. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CLI="${AIDD_CLI:-$ROOT/dist/cli.js}" +FIXTURE="$ROOT/tests/fixtures/framework-real" +PLUGIN="aidd-vcs" +REF="$PLUGIN@aidd-framework" + +[[ -f "$CLI" ]] || { echo "FATAL: $CLI missing — run 'pnpm build' first"; exit 1; } + +PASS=0; FAIL=0; SKIP=0 +ok() { PASS=$((PASS+1)); echo " ✓ $1"; } +bad() { FAIL=$((FAIL+1)); echo " ✗ $1"; } +skip() { SKIP=$((SKIP+1)); echo " ~ $1"; } + +TMPROOT=$(mktemp -d -t aidd-smoke-collision-XXXXXXXX) +trap 'rm -rf "$TMPROOT"' EXIT +export HOME="$TMPROOT/home" USERPROFILE="$TMPROOT/home" AIDD_USER_CONFIG_DIR="$TMPROOT/config" +export CODEX_HOME="$HOME/.codex" +unset XDG_CONFIG_HOME AIDD_TELEMETRY_DIR + +CODEX_CONFIG="$CODEX_HOME/config.toml" +CODEX_CACHE_WITNESS="$CODEX_HOME/plugins/cache/aidd-framework/foreign-payload/bytes" +COPILOT_SETTINGS="$HOME/.copilot/settings.json" +CURSOR_MANIFEST="$HOME/.cursor/plugins/local/$PLUGIN/.cursor-plugin/plugin.json" +CODEX_SECTION="[plugins.\"$REF\"]" +CURSOR_MINE='{"name":"aidd-vcs","version":"0.0.1","mine":true}' +CODEX_CACHE_MINE='foreign host cache bytes must remain unchanged' +CODEX_FOREIGN_FIXTURE="$ROOT/tests/fixtures/plugins/codex-format/marketplace-foreign-aidd" +COPILOT_FOREIGN_FIXTURE="$ROOT/tests/fixtures/plugins/copilot-format/marketplace-multi-sample" + +TOOLS=(cursor) +for t in codex copilot; do + if command -v "$t" >/dev/null 2>&1; then TOOLS+=("$t"); else skip "$t not installed on PATH"; fi +done +mkdir -p "$CODEX_HOME" "$(dirname "$CODEX_CACHE_WITNESS")" "$(dirname "$COPILOT_SETTINGS")" "$(dirname "$CURSOR_MANIFEST")" +printf '%s\nenabled = true\n' "$CODEX_SECTION" > "$CODEX_CONFIG" +printf '%s\n' "$CODEX_CACHE_MINE" > "$CODEX_CACHE_WITNESS" +if command -v codex >/dev/null 2>&1; then + codex plugin marketplace add "$CODEX_FOREIGN_FIXTURE" --json >"$TMPROOT/codex-seed.log" 2>&1 \ + && ok "codex: foreign same-name catalogue seeded" || bad "codex: foreign catalogue seed failed" +fi +if command -v copilot >/dev/null 2>&1; then + copilot plugin marketplace add "$COPILOT_FOREIGN_FIXTURE" >"$TMPROOT/copilot-seed.log" 2>&1 \ + && ok "copilot: foreign same-name catalogue seeded" || bad "copilot: foreign catalogue seed failed" +fi +node -e 'const fs=require("fs");const p=process.argv[1],r=process.argv[2];let s={};try{s=JSON.parse(fs.readFileSync(p,"utf8"))}catch{}s.enabledPlugins={...(s.enabledPlugins||{}),[r]:true};fs.writeFileSync(p,JSON.stringify(s)+"\n")' "$COPILOT_SETTINGS" "$REF" +printf '%s\n' "$CURSOR_MINE" > "$CURSOR_MANIFEST" + +copilot_key() { + node -e "const s=JSON.parse(require(\"fs\").readFileSync(process.argv[1],\"utf8\"));console.log(String(s.enabledPlugins && s.enabledPlugins[process.argv[2]]))" "$COPILOT_SETTINGS" "$REF" 2>/dev/null || echo unreadable +} + +codex_catalog_source() { + codex plugin marketplace list --json 2>/dev/null | node -e 'let s="";process.stdin.on("data",b=>s+=b);process.stdin.on("end",()=>{try{const m=JSON.parse(s).marketplaces.find(x=>x.name==="aidd-framework");console.log(m?.marketplaceSource?.source??"unproven")}catch{console.log("unreadable")}})' 2>/dev/null || echo unreadable +} + +copilot_catalog_source() { + node -e 'try{const s=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(s.extraKnownMarketplaces?.["aidd-framework"]?.source?.path??"unproven")}catch{console.log("unreadable")}' "$COPILOT_SETTINGS" 2>/dev/null || echo unreadable +} + +still_theirs() { + local step="$1" t + for t in "${TOOLS[@]}"; do + case "$t" in + codex) grep -qxF "$CODEX_SECTION" "$CODEX_CONFIG" && ok "codex: the person's $REF is still enabled after $step" \ + || bad "codex: the person's $REF section is gone after $step" + [[ "$(codex_catalog_source)" == "$CODEX_FOREIGN_FIXTURE" ]] \ + && ok "codex: foreign same-name catalogue source unchanged after $step" \ + || bad "codex: foreign same-name catalogue source changed after $step" + [[ "$(cat "$CODEX_CACHE_WITNESS" 2>/dev/null)" == "$CODEX_CACHE_MINE" ]] \ + && ok "codex: foreign marketplace cache bytes are unchanged after $step" \ + || bad "codex: foreign marketplace cache bytes changed or vanished after $step" ;; + copilot) [[ "$(copilot_key)" == "true" ]] && ok "copilot: the person's $REF is still enabled after $step" \ + || bad "copilot: the person's $REF reads '$(copilot_key)' after $step" + [[ "$(copilot_catalog_source)" == "$COPILOT_FOREIGN_FIXTURE" ]] \ + && ok "copilot: foreign same-name catalogue source unchanged after $step" \ + || bad "copilot: foreign same-name catalogue source changed after $step" ;; + cursor) [[ "$(cat "$CURSOR_MANIFEST" 2>/dev/null)" == "$CURSOR_MINE" ]] && ok "cursor: the person's plugin.json is untouched after $step" \ + || bad "cursor: the person's plugin.json changed or vanished after $step" ;; + esac + done +} + +PROJECT="$TMPROOT/project"; mkdir -p "$PROJECT"; (cd "$PROJECT" && git init -q) +ai=$(IFS=,; echo "${TOOLS[*]}") +echo "CLI: $CLI"; echo "Hosts: $ai" + +(cd "$PROJECT" && node "$CLI" setup --source local --path "$FIXTURE" --ai "$ai" --plugins "$PLUGIN" --yes) "$TMPROOT/setup.log" 2>&1 \ + && ok "setup --ai $ai --plugins $PLUGIN" || bad "setup exited $? (see output below)" +still_theirs "setup" +(cd "$PROJECT" && node "$CLI" clean --force) "$TMPROOT/clean.log" 2>&1 \ + && ok "clean --force" || bad "clean exited $?" +still_theirs "clean" + +if [[ "$FAIL" -gt 0 ]]; then + echo "--- setup output"; cat "$TMPROOT/setup.log"; echo "--- clean output"; cat "$TMPROOT/clean.log" +fi +echo "PASS: $PASS FAIL: $FAIL SKIP: $SKIP" +[[ "$FAIL" -eq 0 ]] diff --git a/cli/scripts/smoke-real.sh b/cli/scripts/smoke-real.sh index 51ff408b9..22d718f66 100644 --- a/cli/scripts/smoke-real.sh +++ b/cli/scripts/smoke-real.sh @@ -615,6 +615,46 @@ else skip "opencode bridge check (opencode not installed)" fi +section "opencode: a real session under aidd-telemetry writes a journal line" +# The bridge check above installs the smoke fixture, which journals nothing, so it passed while +# every OpenCode session recorded nothing (#812). This installs the repository's own +# aidd-telemetry on the layout a user gets, and asks the journal itself. +if [[ -n "${PRESENT[opencode]:-}" ]]; then + PROJ_T=$(mktemp -d "$TMPROOT/proj-telemetry.XXXXXX"); (cd "$PROJ_T" && git init -q) + PROJECTS+=("$PROJ_T") + MKT_T="$MKT-telemetry" + run "setup (opencode, files only)" 0 "" "$PROJ_T" -- \ + node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai opencode \ + --no-default-marketplace --plugins none --yes + run "marketplace add $MKT_T (this repository)" 0 "" "$PROJ_T" -- \ + node "$CLI" marketplace add "$MKT_T" "$ROOT/.." --scope project --yes + run "plugin install aidd-telemetry -> opencode" 0 "" "$PROJ_T" -- \ + node "$CLI" plugin install aidd-telemetry --tool opencode --from "$MKT_T" --yes + run "telemetry on" 0 "" "$PROJ_T" -- node "$CLI" telemetry on --yes + + tel_out=$(mktemp) + ( cd "$PROJ_T" && exec perl -e 'alarm shift; exec @ARGV' 90 opencode run "say ok" ) "$tel_out" 2>&1 + tel_rc=$? + cat "$tel_out" >> "$LOGFILE" + # The session opens on its first call, so only a turn that completed can prove an empty + # journal: exit 0 with no line is #812 itself, a model that never answered proves nothing. + if grep -qsE '"type":"session_start".*"tool":"opencode"' "$PROJ_T"/aidd_docs/runs/*.jsonl; then + ok "opencode: the run journal holds a session_start line from opencode" + elif [[ "$tel_rc" -eq 0 ]]; then + bad "opencode: the turn completed and the run journal holds no session_start line" \ + "$(ls -la "$PROJ_T/aidd_docs/runs" 2>&1; cat "$tel_out")" + elif [[ "$tel_rc" -eq 142 ]]; then + skip "opencode journal: the model never answered within 90s, so the journal proves nothing" + elif grep -qiE "auth|api key|provider|not logged in|credential" "$tel_out"; then + skip "opencode journal: needs provider auth on this machine — exit $tel_rc" + else + bad "opencode: exit $tel_rc and the run journal holds no session_start line" "$(cat "$tel_out")" + fi + rm -f "$tel_out" +else + skip "opencode journal check (opencode not installed)" +fi + # --- Phase C1: the guard refuses a genuinely different catalog under the same name --- # Identity is a catalog's declared name plus its plugin set, never a path and never a version # (`marketplace-source-conflict.ts`), so this fixture keeps the name `$MKT` and drops its one diff --git a/cli/scripts/smoke-tools.sh b/cli/scripts/smoke-tools.sh index da217b49c..57dd7971a 100755 --- a/cli/scripts/smoke-tools.sh +++ b/cli/scripts/smoke-tools.sh @@ -17,7 +17,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" CLI="$ROOT/dist/cli.js" FRAMEWORK_FIXTURE="$ROOT/tests/fixtures/framework" -AI_TOOLS=(claude cursor copilot codex opencode) +AI_TOOLS=(claude cursor copilot codex opencode kilo) IDE_TOOLS=(vscode) # Canonical leaf-command surface. Coverage = exercised / total. @@ -42,6 +42,27 @@ ok() { PASS=$((PASS+1)); echo " ✓ $1"; } bad() { FAIL=$((FAIL+1)); FAILURES+=("$1"$'\n'"${2:-}"); echo " ✗ $1"; } skip() { SKIP=$((SKIP+1)); echo " ~ $1"; } section() { echo; echo "=== $1 === [$(date +%H:%M:%S)]"; } +project_tree_hash() { + (cd "$1" && node -e ' + const fs = require("node:fs"); + const path = require("node:path"); + const hash = require("node:crypto").createHash("sha256"); + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) walk(absolute); + else if (entry.isFile()) { + hash.update(path.relative(process.cwd(), absolute)); + hash.update("\0"); + hash.update(fs.readFileSync(absolute)); + hash.update("\0"); + } + } + } + walk(process.cwd()); + process.stdout.write(hash.digest("hex")); + ' ) +} PARENTS=" plugin marketplace auth framework telemetry " # A parent one level deeper than PARENTS, so a covered key needs three words there, not two. @@ -180,14 +201,15 @@ section "update --check" out=$(cd "$ROOT" && node "$CLI" update --check 2>&1); rc=$? if [[ "$rc" -eq 0 || "$rc" -eq 1 ]]; then mark_covered "update"; ok "update --check (exit $rc)"; else bad "update crashed (exit $rc)" "$out"; fi -# Comparing the file list before and after is the only assertion that proves `--dry-run` -# wrote nothing. +# Compare exact file paths and contents before/after: `--dry-run` must write nothing. P_DRY=$(new_project) (cd "$P_DRY" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --plugins none --yes >/dev/null 2>&1) -before_dry=$(cd "$P_DRY" && find . -type f | sort | md5) +before_dry=$(project_tree_hash "$P_DRY"); before_dry_rc=$? run "update --dry-run" "0|1" "" "$P_DRY" -- update --dry-run -after_dry=$(cd "$P_DRY" && find . -type f | sort | md5) -if [[ "$before_dry" == "$after_dry" ]]; then +after_dry=$(project_tree_hash "$P_DRY"); after_dry_rc=$? +if [[ "$before_dry_rc" -ne 0 || "$after_dry_rc" -ne 0 ]]; then + bad "--dry-run project tree hash failed" +elif [[ "$before_dry" == "$after_dry" ]]; then ok "--dry-run wrote nothing" else bad "--dry-run changed the project tree" @@ -210,15 +232,17 @@ run "marketplace add --overwrite" 0 "" "$P_MKT" -- marketplace add local "$MKT_S # Passing `--scope` is not enough: the two values must write to different places. P_SCOPE=$(new_project) (cd "$P_SCOPE" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --plugins none --yes >/dev/null 2>&1) -run "marketplace add --scope project" 0 "" "$P_SCOPE" -- marketplace add scoped "$MKT_SRC" --yes --scope project +PROJECT_MKT_SRC="$TMPROOT/project-mkt-src"; mkdir -p "$PROJECT_MKT_SRC/.claude-plugin" +printf '%s' '{"name":"project-mkt","owner":{"name":"smoke"},"version":"1.0.0","plugins":[]}' > "$PROJECT_MKT_SRC/.claude-plugin/marketplace.json" +run "marketplace add --scope project" 0 "" "$P_SCOPE" -- marketplace add scoped "$PROJECT_MKT_SRC" --yes --scope project proj_reg="$P_SCOPE/.aidd/marketplaces.json" if [[ -f "$proj_reg" ]] && grep -q "scoped" "$proj_reg"; then ok "--scope project writes the project registry" else bad "--scope project did not write $proj_reg" fi -# A second source with its own manifest name: the tool keys its registry by the name inside -# the marketplace, so two aidd marketplaces sharing a source would collide rather than scope. +# A third source with its own manifest name: the tool keys its registry by the name inside +# the marketplace, so each scope assertion needs a distinct host name. USER_MKT_SRC="$TMPROOT/user-mkt-src"; mkdir -p "$USER_MKT_SRC/.claude-plugin" printf '%s' '{"name":"user-mkt","owner":{"name":"smoke"},"version":"1.0.0","plugins":[]}' > "$USER_MKT_SRC/.claude-plugin/marketplace.json" run "marketplace add --scope user" 0 "" "$P_SCOPE" -- marketplace add userscoped "$USER_MKT_SRC" --yes --scope user @@ -234,9 +258,9 @@ if command -v claude >/dev/null 2>&1; then claude_local="$P_SCOPE/.claude/settings.local.json" claude_home="$HOME/.claude/settings.json" # Names the marketplace, not the generic `extraKnownMarketplaces` key any declaration would - # satisfy. Keyed by `local-mkt`, the catalog's own declared name: this file is written by + # satisfy. Keyed by `project-mkt`, the catalog's own declared name: this file is written by # `hostName`, never by `scoped`, aidd's local alias for the same entry. - if [[ -f "$claude_local" ]] && grep -q '"local-mkt"' "$claude_local"; then + if [[ -f "$claude_local" ]] && grep -q '"project-mkt"' "$claude_local"; then ok "claude declares the project marketplace at local scope" else bad "claude has no local-scope declaration in $claude_local" @@ -278,8 +302,13 @@ if true; then run "setup --release (local source)" 0 "" "$P_REL" -- \ setup --source local --path "$FRAMEWORK_FIXTURE" --release v1.0.0 --ai claude --plugins none --yes for t in "${AI_TOOLS[@]}"; do - [[ -d "$BASE/.${t}" || ( "$t" == copilot && -d "$BASE/.github" ) ]] \ - && ok "$t dir present" || bad "$t dir missing after --ai all" + if [[ "$t" == copilot ]]; then + [[ ! -d "$BASE/.github" ]] \ + && ok "copilot declarative settings absent without native source proof" \ + || bad "copilot declarative settings written without native source proof" + else + [[ -d "$BASE/.${t}" ]] && ok "$t dir present" || bad "$t dir missing after --ai all" + fi done [[ -d "$BASE/.vscode" ]] && ok "vscode dir present" || bad "vscode dir missing" # Cursor is `installScope: "user"`, so its plugin files land under $HOME and never under @@ -322,7 +351,7 @@ if true; then run "sync --force" 0 "" "$BASE" -- sync --force repaired "sync --force" "$tgt" - section "framework install/update/remove --tool × all 5 AI tools + vscode" + section "framework install/update/remove --tool × all 6 AI tools + vscode" run "framework update (all)" 0 "" "$BASE" -- framework update run "framework rules" 0 "" "$BASE" -- framework rules run "framework rules --json" 0 "" "$BASE" -- framework rules --json @@ -362,10 +391,34 @@ if true; then (cd "$P_PLUG" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai all --plugins none --yes >/dev/null 2>&1) for t in "${AI_TOOLS[@]}"; do run "plugin install aidd-test → $t" 0 "" "$P_PLUG" -- plugin install aidd-test --tool "$t" --yes - run "plugin remove → $t" 0 "" "$P_PLUG" -- plugin remove aidd-test --tool "$t" - # `--from` names the marketplace explicitly. - run "plugin install --from → $t" 0 "" "$P_PLUG" -- \ - plugin install aidd-test --tool "$t" --from aidd-framework --yes + # An absent host permits local removal with a warning; an available host must first + # prove its native source. Exercise the matching contract, never infer native absence. + if [[ "$t" == copilot ]] && command -v copilot >/dev/null 2>&1; then + copilot_project_before=$(project_tree_hash "$P_PLUG") + copilot_home_before=$(project_tree_hash "$HOME") + run "plugin remove → copilot (unproven source refusal)" 1 \ + "no recorded native catalogue source" "$P_PLUG" -- plugin remove aidd-test --tool copilot + copilot_project_after=$(project_tree_hash "$P_PLUG") + copilot_home_after=$(project_tree_hash "$HOME") + [[ -n "$copilot_project_before" && -n "$copilot_home_before" && \ + "$copilot_project_before" == "$copilot_project_after" && \ + "$copilot_home_before" == "$copilot_home_after" ]] \ + && ok "copilot refusal wrote no project or home bytes" \ + || bad "copilot refusal changed project or home bytes" + run "plugin install --from → copilot (no duplicate after refusal)" 1 \ + "already installed" "$P_PLUG" -- \ + plugin install aidd-test --tool copilot --from aidd-framework --yes + elif [[ "$t" == copilot ]]; then + run "plugin remove → copilot (missing host warns, local removal succeeds)" 0 \ + "copilot CLI not found on PATH" "$P_PLUG" -- plugin remove aidd-test --tool copilot + run "plugin install --from → copilot (local reinstall without host)" 0 "" "$P_PLUG" -- \ + plugin install aidd-test --tool copilot --from aidd-framework --yes + else + run "plugin remove → $t" 0 "" "$P_PLUG" -- plugin remove aidd-test --tool "$t" + # `--from` names the marketplace explicitly. + run "plugin install --from → $t" 0 "" "$P_PLUG" -- \ + plugin install aidd-test --tool "$t" --from aidd-framework --yes + fi done run "plugin remove aidd-test (claude)" 0 "" "$P_PLUG" -- plugin remove aidd-test --tool claude @@ -547,14 +600,14 @@ has_subcommands() { } leaves_under() { - local path=("$@") name + local name # `cut -d'|'`: commander prints an alias as `update|upgrade`, one command with two names. - for name in $(node "$CLI" "${path[@]}" --help 2>/dev/null | awk '/^Commands:/{f=1;next} f && /^ [a-z]/{print $1}' | cut -d'|' -f1); do + for name in $(node "$CLI" "$@" --help 2>/dev/null | awk '/^Commands:/{f=1;next} f && /^ [a-z]/{print $1}' | cut -d'|' -f1); do [[ "$name" == "help" ]] && continue - if has_subcommands "${path[@]}" "$name"; then - leaves_under "${path[@]}" "$name" + if has_subcommands "$@" "$name"; then + leaves_under "$@" "$name" else - echo "${path[*]} $name" | sed 's/^ *//' + echo "$* $name" | sed 's/^ *//' fi done } diff --git a/cli/src/contexts/distribution/application/marketplace-add-use-case.ts b/cli/src/contexts/distribution/application/marketplace-add-use-case.ts index 456ca7e4e..986bcffe5 100644 --- a/cli/src/contexts/distribution/application/marketplace-add-use-case.ts +++ b/cli/src/contexts/distribution/application/marketplace-add-use-case.ts @@ -7,7 +7,15 @@ import { import type { Prompter } from "../../../kernel/ports/prompter.js"; import type { MarketplaceScope } from "../../../kernel/scope.js"; import type { PluginSource } from "../../../kernel/source.js"; -import type { MarketplaceRemoveUseCase } from "../../framework/application/flows/marketplace-remove-use-case.js"; +import type { + MarketplaceRemoveOptions, + MarketplaceRemoveResult, +} from "../../framework/application/flows/marketplace-remove-use-case.js"; + +export interface MarketplaceRemover { + execute(options: MarketplaceRemoveOptions): Promise; +} + import { FRAMEWORK_MARKETPLACE_NAME, Marketplace } from "../domain/marketplace.js"; import type { MarketplaceRegistry } from "../domain/ports/marketplace-registry.js"; import type { MarketplaceTrustStore } from "../domain/ports/marketplace-trust-store.js"; @@ -32,7 +40,7 @@ export class MarketplaceAddUseCase { private readonly trustStore: MarketplaceTrustStore, private readonly resolveMarketplace: ResolveMarketplaceUseCase, private readonly prompter: Prompter, - private readonly removeUseCase: MarketplaceRemoveUseCase + private readonly removeUseCase: MarketplaceRemover ) {} async execute(options: MarketplaceAddOptions): Promise { @@ -69,7 +77,7 @@ export class MarketplaceAddUseCase { const found = existing.find((m) => m.name === name); if (!found) return; if (!overwrite) throw new MarketplaceAlreadyRegisteredError(name); - await this.removeUseCase.execute({ name, projectRoot, autoConfirm: true }); + await this.removeUseCase.execute({ name, projectRoot, autoConfirm: true, scope: found.scope }); } private async ensureTrust(options: MarketplaceAddOptions): Promise { diff --git a/cli/src/contexts/framework/application/clean-use-case.ts b/cli/src/contexts/framework/application/clean-use-case.ts index 0e59ccdb6..f9c53fd2d 100644 --- a/cli/src/contexts/framework/application/clean-use-case.ts +++ b/cli/src/contexts/framework/application/clean-use-case.ts @@ -19,9 +19,11 @@ import { resolveHomeDir } from "../../../kernel/reading/home-dir.js"; import type { MarketplaceScope } from "../../../kernel/scope.js"; import type { AiToolId, ToolId } from "../../../kernel/tool.js"; import { isAiToolId } from "../../../kernel/tool.js"; +import { FRAMEWORK_MARKETPLACE_NAME } from "../../distribution/domain/marketplace.js"; import type { MarketplaceRegistry } from "../../distribution/domain/ports/marketplace-registry.js"; import type { HostMarketplaceRegistryReader } from "../../tools/domain/ports/host-marketplace-registry-reader.js"; import type { HostPluginRegistryReader } from "../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../tools/domain/ports/native-plugin-activator.js"; import { machineLocalFilesOf, @@ -36,13 +38,23 @@ import type { InstalledPlugin } from "../domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../domain/ports/user-source-references.js"; import type { GitignoreUseCase } from "./gitignore-use-case.js"; +import { + assertNoForeignNativeRefs, + inspectNativeMarketplaceSource, +} from "./ownership/native-marketplace-source-proof.js"; +import { + detachNativePluginRefs, + machineNativePluginClaim, +} from "./ownership/native-plugin-ownership.js"; +import { assertProjectMcpEntriesRemovable } from "./ownership/project-plugin-cleanup.js"; +import { detachUserPlugin } from "./ownership/user-plugin-ownership.js"; import { deletePluginFilesForTool } from "./plugin/plugin-helpers.js"; import { bestEffortNativeCall } from "./shared/best-effort-native-call.js"; import { purgeAllNativeCaches, type UndoneToolRegistrations, } from "./shared/purge-native-marketplace-cache.js"; -import { removeProjectHooks } from "./shared/remove-project-hooks.js"; +import { assertProjectHooksRemovable, removeProjectHooks } from "./shared/remove-project-hooks.js"; import { resolveUninstallScopeOrder } from "./shared/resolve-uninstall-scope.js"; import { describeGuardedPluginRefMessage, @@ -54,7 +66,7 @@ import { } from "./shared/shared-source-reference-support.js"; import { userScopeFilesSafeToDelete } from "./shared/user-scope-plugin-files.js"; -/** What dropping this project's own reference to the shared source found — `undefined` only +/** What reading this project's shared source reference found — `undefined` only * when there is nothing to guard on at all: the port is absent, or no shared, machine-scope * marketplace is registered locally. `otherProjects` is read regardless of whether this * project's own claim was there to drop, since another project's claim is a fact worth @@ -62,6 +74,7 @@ import { userScopeFilesSafeToDelete } from "./shared/user-scope-plugin-files.js" * per tool into that tool's `hostName` — never the alias, which a host never learns. */ interface SharedSourceReferenceOutcome { readonly alias: string; + readonly resolvedRoot: string; readonly otherProjects: readonly string[]; } @@ -134,7 +147,9 @@ export class CleanUseCase { private readonly hostPluginRegistries: ReadonlyMap< AiToolId, HostPluginRegistryReader - > = new Map() + > = new Map(), + private readonly userManifestRepo?: ManifestRepository, + private readonly nativeSources: ReadonlyMap = new Map() ) {} async execute(options: CleanOptions): Promise { @@ -147,10 +162,26 @@ export class CleanUseCase { const preview = await this.buildPreview(manifest, home, options.projectRoot); const dryRunResult = await this.confirmOrDryRun(options, preview); if (dryRunResult !== null) return dryRunResult; - // Decremented exactly once per run, before the per-tool loop: the shared source's reference - // count is a project-level fact, and claude, codex and copilot can each carry their own ref, - // so decrementing inside that loop would drop this project's claim once per tool. - const sharedSourceOutcome = await this.dropSharedSourceReference(options.projectRoot); + await this.assertNativeSourcesUnchanged(manifest, options.projectRoot); + for (const toolId of manifest.getInstalledToolIds()) { + if (!isAiToolId(toolId)) continue; + for (const plugin of manifest.getPlugins(toolId)) { + if (projectHooksFileOf(toolId) !== undefined) { + await assertProjectHooksRemovable(this.fs, plugin, toolId, options.projectRoot); + } + await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, options.projectRoot); + } + } + // Read the other projects before host cleanup, but keep this project's claim until every + // local cleanup step succeeds. A failed file or gitignore cleanup must not free the source. + const sharedSourceOutcome = await this.withSharedSourceClaims( + options.projectRoot, + async (references, alias, resolvedRoot) => ({ + alias, + resolvedRoot, + otherProjects: await otherProjectsReferencing(references, resolvedRoot), + }) + ); // Undoing a host's own registration must happen before any of the rest: the tool's CLI // resolves the marketplace name against the built tree under `.aidd/cache/`, which // `removeAiddState` deletes next, and a host may refuse to unregister a source that is gone. @@ -169,9 +200,76 @@ export class CleanUseCase { await this.removeAiddState(options.projectRoot); // Exactly what the pipeline added on install, never a subset of it. await this.gitignoreUseCase.remove(options.projectRoot, aiddGitignoreEntries(manifest)); + // One project-level decrement, only after the local projection has been cleaned successfully. + await this.dropSharedSourceReference(sharedSourceOutcome?.resolvedRoot); + const nativeRefs = new Map(); + for (const toolId of manifest.getInstalledToolIds()) { + const refs = manifest.getNativeRegistrations(toolId)?.pluginRefs; + if (refs !== undefined && refs.length > 0) nativeRefs.set(toolId, refs); + } + await detachNativePluginRefs(this.userManifestRepo, this.fs, options.projectRoot, nativeRefs); + for (const toolId of manifest.getInstalledToolIds()) { + if (!isAiToolId(toolId)) continue; + for (const plugin of manifest.getPlugins(toolId)) { + if (plugin.scope === "user") { + await detachUserPlugin( + this.userManifestRepo, + this.fs, + toolId, + plugin.name, + options.projectRoot + ); + } + } + } return { dryRun: false, manifestFound: true, preview, fileCount: deleted }; } + /** A project clean must not decrement shared claims before proving the host names it may undo. */ + private async assertNativeSourcesUnchanged( + manifest: Manifest, + projectRoot: string + ): Promise { + const projectMarketplaces = (await this.marketplaceRegistry?.list(projectRoot)) ?? []; + const machineManifest = await this.userManifestRepo?.load(); + for (const toolId of manifest.getInstalledToolIds()) { + if (!isAiToolId(toolId)) continue; + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) continue; + const activator = this.activators.get(registrations.binary); + if (activator === undefined || !activator.isAvailable()) continue; + for (const registration of registrations.marketplaces) { + if ( + projectMarketplaces.find((marketplace) => marketplace.name === registration.alias) + ?.scope !== "project" + ) { + continue; + } + const proof = await inspectNativeMarketplaceSource( + this.nativeSources.get(toolId), + projectRoot, + registration + ); + if (proof.status !== "owned") { + throw new Error( + proof.reason ?? + `${toolId}: catalogue '${registration.hostName}' is absent on the host; clean refused until manual reconciliation.` + ); + } + const machineRefs = + machineManifest + ?.getNativeRegistrations(toolId) + ?.pluginClaims?.map((claim) => claim.ref) ?? []; + await assertNoForeignNativeRefs( + this.hostPluginRegistries.get(toolId), + registration.hostName, + new Set([...registrations.pluginRefs, ...machineRefs]), + projectRoot + ); + } + } + } + // `config.json` is the committed telemetry switch: a file clean did not write, so clean never // removes it. Everything AIDD did write must go before the emptiness check, or its own presence // blocks a removal that should happen — the registry `marketplace add` writes included. @@ -306,36 +404,57 @@ export class CleanUseCase { ); return false; } - return bestEffortNativeCall( - this.logger, - () => activator.removeMarketplace(hostName, marketplace.scope), - `${binary} marketplace remove '${hostName}'` - ); + const removeProjectRegistration = async (): Promise => { + if (pluginEnablementIsMachineGlobal(toolId)) { + const machine = await this.userManifestRepo?.load(); + if (machine === undefined || machine === null) { + if (marketplace.name !== FRAMEWORK_MARKETPLACE_NAME) { + this.logger.warn( + `${binary}: '${hostName}' is machine-global but no user manifest can prove no other project still uses it — left registered for manual cleanup.` + ); + return false; + } + } + const claims = + machine + ?.getNativeRegistrations(toolId) + ?.pluginClaims?.filter((claim) => claim.ref.endsWith(`@${hostName}`)) ?? []; + if (claims.length > 0) { + const others = [ + ...new Set( + claims.flatMap((claim) => + claim.dependents.filter((dependent) => dependent !== projectRoot) + ) + ), + ]; + this.logger.warn( + `${binary}: '${hostName}' carries AIDD-owned machine plugin refs — left registered for explicit user-scope removal.${others.length > 0 ? ` Still needed by ${others.join(", ")}.` : ""}` + ); + return false; + } + } + return bestEffortNativeCall( + this.logger, + () => activator.removeMarketplace(hostName, marketplace.scope), + `${binary} marketplace remove '${hostName}'` + ); + }; + return this.userManifestRepo?.withExclusiveAccess === undefined + ? removeProjectRegistration() + : this.userManifestRepo.withExclusiveAccess(removeProjectRegistration); } - /** Decrements this project's own claim exactly once per `clean` run, independent of how many - * tools' registrations name it: the count in `references.json` is per project, never per tool. - * Never reads a "current" CLI version to decide which key to touch, so a self-update between - * the `sync` that wrote the reference and this `clean` cannot strand it. `undefined` only when - * the port was never wired in, or no shared marketplace is registered locally — never merely - * because this project's own claim was already missing. */ - private async dropSharedSourceReference( - projectRoot: string - ): Promise { - return this.withSharedSourceClaims( - projectRoot, - async (userSourceReferences, alias, resolvedRoot) => { - // A no-op when this project's own registry never held the shared entry, which must never - // collapse into "no other projects": another project's claim is worth guarding on - // regardless, so `otherProjects` is always read in full. - await userSourceReferences.removeReference(resolvedRoot); - const otherProjects = await otherProjectsReferencing(userSourceReferences, resolvedRoot); - return { alias, otherProjects }; - } + /** Decrements this project's claim exactly once after local cleanup. The resolved root was + * captured before `.aidd/` and its local marketplace registry were deleted. */ + private async dropSharedSourceReference(resolvedRoot: string | undefined): Promise { + const references = this.userSourceReferences; + if (references === undefined || resolvedRoot === undefined) return; + await toleratingUnreadableSourceReferences(this.logger, undefined, () => + references.removeReference(resolvedRoot) ); } - /** Shared preamble behind `dropSharedSourceReference` and `previewSharedSourceOtherProjects`: + /** Shared preamble behind the cleanup read and `previewSharedSourceOtherProjects`: * `undefined` when the port was never wired in or no shared, machine-scope registration exists * to act on. `action` alone decides whether the run only reads or also writes. */ private async withSharedSourceClaims( @@ -417,6 +536,27 @@ export class CleanUseCase { registrations: NativeRegistrations, sharedSourceOutcome: SharedSourceReferenceOutcome | undefined ): Promise { + const claim = await machineNativePluginClaim(this.userManifestRepo, toolId, ref); + if (claim !== undefined) { + const others = claim.dependents.filter((dependent) => dependent !== projectRoot); + this.logger.warn( + `${binary}: '${ref}' is an AIDD-owned machine plugin ref — left enabled; project claim detached after local clean.${others.length > 0 ? ` Still needed by ${others.join(", ")}.` : ""}` + ); + return; + } + if (pluginEnablementIsMachineGlobal(toolId)) { + const sharedHostName = registrations.marketplaces.find( + (registration) => registration.alias === sharedSourceOutcome?.alias + )?.hostName; + const otherProjects = + sharedHostName !== undefined && ref.endsWith(`@${sharedHostName}`) + ? (sharedSourceOutcome?.otherProjects ?? []) + : []; + this.logger.warn( + `${binary}: '${ref}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.${otherProjects.length > 0 ? ` Still needed by ${otherProjects.join(", ")}.` : ""}` + ); + return; + } const guardMessage = this.describeGuardedPluginRef( binary, toolId, @@ -428,7 +568,40 @@ export class CleanUseCase { this.logger.warn(guardMessage); return; } + const sourceRegistration = registrations.marketplaces.find((registration) => + ref.endsWith(`@${registration.hostName}`) + ); + if (sourceRegistration === undefined) { + this.logger.warn(`${binary}: '${ref}' has no recorded host catalogue — left enabled.`); + return; + } + const proof = await inspectNativeMarketplaceSource( + isAiToolId(toolId) ? this.nativeSources.get(toolId) : undefined, + projectRoot, + sourceRegistration + ); + if (proof.status !== "owned") { + this.logger.warn( + `${binary}: '${ref}' left enabled because its current host catalogue is unproven. ${proof.reason ?? "Reconcile manually."}` + ); + return; + } const reader = isAiToolId(toolId) ? this.hostPluginRegistries.get(toolId) : undefined; + const machineRefs = + (await this.userManifestRepo?.load()) + ?.getNativeRegistrations(toolId) + ?.pluginClaims?.map((machineClaim) => machineClaim.ref) ?? []; + try { + await assertNoForeignNativeRefs( + reader, + sourceRegistration.hostName, + new Set([...registrations.pluginRefs, ...machineRefs]), + projectRoot + ); + } catch (error) { + this.logger.warn(`${binary}: '${ref}' left enabled; ${String(error)}`); + return; + } const manifestScope = this.manifestScopeForRef(manifest, toolId, registrations, ref); const order = await resolveUninstallScopeOrder(reader, ref, projectRoot, manifestScope); let lastMessage = ""; @@ -535,7 +708,7 @@ export class CleanUseCase { if (projectHooksFileOf(toolId) === undefined || !isAiToolId(toolId)) return 0; let count = 0; for (const plugin of manifest.getPlugins(toolId)) { - if (await removeProjectHooks(this.fs, plugin.name, toolId, projectRoot)) count++; + if (await removeProjectHooks(this.fs, plugin, toolId, projectRoot)) count++; } return count; } @@ -624,6 +797,7 @@ export class CleanUseCase { ): Promise { let count = 0; for (const plugin of manifest.getPlugins(toolId)) { + if (plugin.scope === "user") continue; const files = await this.filesSafeToDelete(plugin, toolId); const deleted = await deletePluginFilesForTool( files, diff --git a/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts b/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts index 37f138110..e02784dd0 100644 --- a/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts +++ b/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { ActiveMachineDependentsError } from "../../../../kernel/errors.js"; import { USER_SOURCE_REFERENCES_FILENAME, userBuiltCacheRoot } from "../../../../kernel/paths.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; @@ -9,12 +10,18 @@ import { type AiToolId, isAiToolId, type ToolId } from "../../../../kernel/tool. import { FRAMEWORK_MARKETPLACE_NAME } from "../../../distribution/domain/marketplace.js"; import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; import type { HostMarketplaceRegistryReader } from "../../../tools/domain/ports/host-marketplace-registry-reader.js"; +import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; import { nativeActivationOf } from "../../../tools/domain/registry.js"; import type { NativeRegistrations } from "../../domain/manifest/native-registrations.js"; import type { Manifest } from "../../domain/manifest.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../../domain/ports/user-source-references.js"; +import { + assertNoForeignNativeRefs, + inspectNativeMarketplaceSource, +} from "../ownership/native-marketplace-source-proof.js"; import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; import { bestEffortNativeCall } from "../shared/best-effort-native-call.js"; import { resolveCacheCandidate } from "../shared/purge-declared-cache.js"; @@ -22,10 +29,7 @@ import { purgeAllNativeCaches, type UndoneToolRegistrations, } from "../shared/purge-native-marketplace-cache.js"; -import { - describeFullRemovalInstruction, - toleratingUnreadableSourceReferences, -} from "../shared/shared-source-reference-support.js"; +import { describeFullRemovalInstruction } from "../shared/shared-source-reference-support.js"; import { userScopeFilesSafeToDelete } from "../shared/user-scope-plugin-files.js"; export interface CleanUserScopeOptions { @@ -39,6 +43,7 @@ export interface CleanUserScopeOptions { export interface CleanUserScopePreview { toolIds: readonly ToolId[]; + activePluginDependents: readonly string[]; /** Every version directory found under `userConfigDir()/cache/built/` — read structurally, * never trusted from any one tool's own manifest entry. */ builtVersions: readonly string[]; @@ -82,18 +87,48 @@ export class CleanUserScopeUseCase { private readonly homeDir: () => string = resolveHomeDir, /** Absent reports no referencing project at all rather than guessing one. */ private readonly userSourceReferences?: UserSourceReferences, - private readonly prompter?: Prompter + private readonly prompter?: Prompter, + private readonly nativeSources: ReadonlyMap< + AiToolId, + NativeMarketplaceSourceReader + > = new Map(), + private readonly hostPluginRegistries: ReadonlyMap< + AiToolId, + HostPluginRegistryReader + > = new Map() ) {} async execute(options: CleanUserScopeOptions): Promise { + if (this.userManifestRepo.withExclusiveAccess !== undefined) { + return this.userManifestRepo.withExclusiveAccess(() => this.executeLocked(options)); + } + return this.executeLocked(options); + } + + private async executeLocked(options: CleanUserScopeOptions): Promise { const manifest = await this.userManifestRepo.load(); const manifestFound = manifest !== null; const preview = await this.buildPreview(manifest); if (!manifestFound) this.logger.info(this.describeNoUserRegistration(preview)); const dryRunResult = await this.confirmOrDryRun(options, preview, manifestFound); if (dryRunResult !== null) return dryRunResult; + if (preview.activePluginDependents.length > 0) { + throw new ActiveMachineDependentsError( + "user-scope plugin files", + preview.activePluginDependents + ); + } + if (preview.referencingProjects.length > 0) { + throw new ActiveMachineDependentsError( + "the shared framework source", + preview.referencingProjects + ); + } if (manifest !== null) { + await this.assertTrackedUserPluginFilesSafe(manifest); + this.assertNativeActivatorsAvailable(manifest); + await this.assertNativeSourcesProven(manifest, options.projectRoot); // Undoing a host's own registration must happen before any purge: a host's own CLI resolves // what it is unregistering against the built tree still on disk, and the purge below removes // exactly that tree. Absent a manifest there is nothing recorded to undo. @@ -115,6 +150,23 @@ export class CleanUserScopeUseCase { private async buildPreview(manifest: Manifest | null): Promise { return { toolIds: manifest?.getInstalledToolIds() ?? [], + activePluginDependents: [ + ...new Set( + manifest?.getInstalledToolIds().flatMap((toolId) => + isAiToolId(toolId) + ? [ + ...manifest + .getPlugins(toolId) + .filter((plugin) => plugin.scope === "user") + .flatMap((plugin) => plugin.dependents), + ...(manifest.getNativeRegistrations(toolId)?.pluginClaims ?? []).flatMap( + (claim) => claim.dependents + ), + ] + : [] + ) ?? [] + ), + ], builtVersions: await this.listBuiltVersions(), referencingProjects: await this.listReferencingProjects(), }; @@ -150,10 +202,28 @@ export class CleanUserScopeUseCase { private async listReferencingProjects(): Promise { if (this.userSourceReferences === undefined) return []; - const userSourceReferences = this.userSourceReferences; - return toleratingUnreadableSourceReferences(this.logger, [], () => - userSourceReferences.listAllReferencingProjects() - ); + return this.userSourceReferences.listAllReferencingProjects(); + } + + private async assertTrackedUserPluginFilesSafe(manifest: Manifest): Promise { + for (const toolId of manifest.getInstalledToolIds()) { + if (!isAiToolId(toolId)) continue; + for (const plugin of manifest.getPlugins(toolId)) { + if (plugin.scope !== "user") continue; + const safe = await userScopeFilesSafeToDelete( + this.fs, + this.logger, + plugin, + toolId, + this.homeDir() + ); + if (safe.size !== plugin.files.size) { + throw new Error( + `User clean refused: a tracked file of '${plugin.name}' escaped its user-scope boundary; canonical claims retained.` + ); + } + } + } } private async confirmOrDryRun( @@ -196,6 +266,79 @@ export class CleanUserScopeUseCase { return undone; } + private assertNativeActivatorsAvailable(manifest: Manifest): void { + for (const toolId of manifest.getInstalledToolIds()) { + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) continue; + const activator = this.activators.get(registrations.binary); + if (activator === undefined || !activator.isAvailable()) + throw new Error( + `${this.describeBinaryAbsent(toolId, registrations)} User clean refused; canonical claims retained.` + ); + } + } + + private async assertNativeSourcesProven(manifest: Manifest, projectRoot: string): Promise { + for (const toolId of manifest.getInstalledToolIds()) { + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) continue; + if ( + !isAiToolId(toolId) || + ((registrations.pluginClaims?.length ?? 0) > 0 && registrations.marketplaces.length === 0) + ) + throw new Error( + `${toolId}: native refs lack a canonical catalogue source; user clean refused.` + ); + const hostNames = new Set(); + for (const { hostName } of registrations.marketplaces) { + if (hostNames.has(hostName)) + throw new Error( + `${toolId}: ambiguous canonical host catalogue '${hostName}'; user clean refused.` + ); + hostNames.add(hostName); + } + const claims = registrations.pluginClaims ?? []; + const claimRefs = new Set(claims.map((claim) => claim.ref)); + if (claimRefs.size !== claims.length) + throw new Error(`${toolId}: duplicate canonical native ref claims; user clean refused.`); + const refsByHost = new Map>( + [...hostNames].map((hostName) => [hostName, new Set()]) + ); + for (const ref of new Set([...registrations.pluginRefs, ...claimRefs])) { + const matches = [...hostNames].filter((hostName) => ref.endsWith(`@${hostName}`)); + if (matches.length !== 1) + throw new Error( + `${toolId}: native ref '${ref}' lacks exactly one canonical catalogue; user clean refused.` + ); + refsByHost.get(matches[0])?.add(ref); + } + for (const registration of registrations.marketplaces) { + const proof = await inspectNativeMarketplaceSource( + this.nativeSources.get(toolId), + projectRoot, + registration + ); + if (proof.status !== "owned") + throw new Error(proof.reason ?? `${toolId}: catalogue source unproven.`); + const owned = new Set( + [...claimRefs].filter((ref) => ref.endsWith(`@${registration.hostName}`)) + ); + const hostRefs = await assertNoForeignNativeRefs( + this.hostPluginRegistries.get(toolId), + registration.hostName, + owned, + projectRoot + ); + for (const ref of refsByHost.get(registration.hostName) ?? []) { + if (hostRefs.get(ref)?.enabled !== true) + throw new Error( + `${toolId}: owned host ref '${ref}' is not enabled; user clean refused.` + ); + } + } + } + } + private async undoToolNativeRegistrations( toolId: ToolId, registrations: NativeRegistrations @@ -203,15 +346,20 @@ export class CleanUserScopeUseCase { const { binary } = registrations; const activator = this.activators.get(binary); if (activator === undefined || !activator.isAvailable()) { - this.logger.warn(this.describeBinaryAbsent(toolId, registrations)); - return undefined; + throw new Error( + `${this.describeBinaryAbsent(toolId, registrations)} User clean refused; canonical claims retained.` + ); } - for (const ref of registrations.pluginRefs) { - bestEffortNativeCall( + for (const { ref } of registrations.pluginClaims ?? []) { + const removed = bestEffortNativeCall( this.logger, () => activator.uninstallPlugin(ref, "user"), `${binary} plugin uninstall '${ref}'` ); + if (!removed) + throw new Error( + `${binary}: user clean refused after plugin uninstall '${ref}' failed; canonical claims retained.` + ); } const removedHostNames = new Set(); for (const { hostName } of registrations.marketplaces) { @@ -220,7 +368,11 @@ export class CleanUserScopeUseCase { () => activator.removeMarketplace(hostName, "user"), `${binary} marketplace remove '${hostName}'` ); - if (removed) removedHostNames.add(hostName); + if (!removed) + throw new Error( + `${binary}: user clean refused after marketplace remove '${hostName}' failed; canonical claims retained.` + ); + removedHostNames.add(hostName); } return removedHostNames; } @@ -233,7 +385,7 @@ export class CleanUserScopeUseCase { const base = `${binary}: registration left in place, the ${binary} CLI is not on the PATH. ` + `It would have unregistered ${registrations.marketplaces.length} marketplace(s) ` + - `and ${registrations.pluginRefs.length} plugin ref(s).`; + `and ${registrations.pluginClaims?.length ?? 0} plugin ref(s).`; if (!isAiToolId(toolId)) return base; const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(this.homeDir()); if (cacheRoot === undefined) return base; @@ -258,7 +410,7 @@ export class CleanUserScopeUseCase { toolId, this.homeDir() ); - await deletePluginFilesForTool(files, plugin.scope, toolId, projectRoot, this.fs); + await deletePluginFilesForTool(files, plugin.scope, toolId, projectRoot, this.fs, "user"); } } } @@ -288,8 +440,8 @@ export class CleanUserScopeUseCase { ); // The manifest's own repository is the single writer of `manifest.json` — deleting through // it, never a second path to the same file, is what keeps that true. - await this.userManifestRepo.delete(); await this.marketplaceRegistry.delete(projectRoot, FRAMEWORK_MARKETPLACE_NAME, "user"); + await this.userManifestRepo.delete(); } /** `cache/built/` and `cache/update-check.json` are this whitelist's only occupants of diff --git a/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts index 5f5f0fb22..3655045ad 100644 --- a/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts +++ b/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts @@ -2,8 +2,8 @@ import { InvalidMarketplaceNameError, MarketplaceNotFoundError, } from "../../../../kernel/errors.js"; -import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Prompter } from "../../../../kernel/ports/prompter.js"; +import type { ToolId } from "../../../../kernel/tool.js"; import { AI_TOOL_IDS, type AiToolId } from "../../../../kernel/tool.js"; import { FRAMEWORK_MARKETPLACE_NAME, @@ -13,12 +13,13 @@ import type { MarketplaceRegistry } from "../../../distribution/domain/ports/mar import type { Manifest } from "../../domain/manifest.js"; import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; -import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; +import type { ProjectPluginCleanup } from "../ownership/project-plugin-cleanup.js"; export interface MarketplaceRemoveOptions { name: string; projectRoot: string; autoConfirm: boolean; + scope?: "project" | "user"; } export interface MarketplaceRemoveResult { @@ -34,7 +35,7 @@ interface OrphanRef { export class MarketplaceRemoveUseCase { constructor( - private readonly fs: FileWriter, + private readonly cleanup: ProjectPluginCleanup, private readonly manifestRepo: ManifestRepository, private readonly registry: MarketplaceRegistry, private readonly prompter: Prompter @@ -49,21 +50,40 @@ export class MarketplaceRemoveUseCase { `"${FRAMEWORK_MARKETPLACE_NAME}" is shared by every project on this machine and is not removed with \`aidd marketplace remove\` — it is removed with the framework itself, by \`aidd clean\`, once machine scope lands there.` ); } - const marketplace = await this.findOrThrow(options.projectRoot, options.name); + if (options.scope === "user") + throw new Error("User-scope marketplace removal requires UserMarketplaceRemoveUseCase."); + const marketplace = await this.findOrThrow(options.projectRoot, options.name, "project"); const manifest = await this.manifestRepo.load(); const orphans = manifest ? this.collectOrphans(manifest, options.name) : []; + const nativeRefs = new Map(); + if (manifest !== null) { + for (const { toolId, plugin } of orphans) { + const hostName = manifest + .getNativeRegistrations(toolId) + ?.marketplaces.find((m) => m.alias === plugin.marketplace)?.hostName; + if (hostName !== undefined) + nativeRefs.set(toolId, [...(nativeRefs.get(toolId) ?? []), `${plugin.name}@${hostName}`]); + } + } const cleanup = await this.shouldCleanup(orphans.length, options.autoConfirm); let removed = 0; if (cleanup && manifest) { removed = await this.removeOrphans(manifest, orphans, options.projectRoot); } await this.registry.delete(options.projectRoot, marketplace.name, marketplace.scope); + if (cleanup) { + await this.cleanup.detachClaims(options.projectRoot, nativeRefs, orphans); + } return { marketplace, removedPluginCount: removed, orphanCount: orphans.length }; } - private async findOrThrow(projectRoot: string, name: string): Promise { + private async findOrThrow( + projectRoot: string, + name: string, + scope: "project" | "user" + ): Promise { const list = await this.registry.list(projectRoot); - const found = list.find((m) => m.name === name); + const found = list.find((m) => m.name === name && m.scope === scope); if (!found) throw new MarketplaceNotFoundError(name); return found; } @@ -90,7 +110,22 @@ export class MarketplaceRemoveUseCase { projectRoot: string ): Promise { for (const { toolId, plugin } of orphans) { - await deletePluginFilesForTool(plugin.files, plugin.scope, toolId, projectRoot, this.fs); + await this.cleanup.assertLocalIntegrationRemovable(toolId, plugin, projectRoot); + } + for (const { toolId, plugin } of orphans) { + await this.cleanup.removeLocalIntegration(toolId, plugin, projectRoot); + await this.cleanup.deleteLocalFiles(toolId, plugin, projectRoot); + const registrations = manifest.getNativeRegistrations(toolId); + const hostName = registrations?.marketplaces.find( + (m) => m.alias === plugin.marketplace + )?.hostName; + if (registrations !== undefined && hostName !== undefined) + manifest.setNativeRegistrations(toolId, { + ...registrations, + pluginRefs: registrations.pluginRefs.filter( + (ref) => ref !== `${plugin.name}@${hostName}` + ), + }); manifest.removePlugin(toolId, plugin.name); } await this.manifestRepo.save(manifest); diff --git a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts index fa9962f09..458151b01 100644 --- a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts +++ b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts @@ -4,7 +4,13 @@ import { NativePluginCliError, UnreadableBuiltCatalogError, } from "../../../../kernel/errors.js"; -import { BUILT_CACHE_SUBDIR } from "../../../../kernel/paths.js"; +import { + BUILT_CACHE_SUBDIR, + parseBuiltMarketplaceDir, + parseUserBuiltMarketplaceDir, + samePath, + samePathSegment, +} from "../../../../kernel/paths.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Hasher } from "../../../../kernel/ports/hasher.js"; @@ -25,16 +31,29 @@ import { pluginSetDifference, } from "../../../tools/domain/marketplace-source-conflict.js"; import type { HostMarketplaceRegistryReader } from "../../../tools/domain/ports/host-marketplace-registry-reader.js"; +import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceReader, +} from "../../../tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; -import { nativeActivationOf, resolvePluginsCapability } from "../../../tools/domain/registry.js"; +import { + nativeActivationOf, + pluginEnablementIsMachineGlobal, + resolvePluginsCapability, +} from "../../../tools/domain/registry.js"; import type { FrameworkBuildTarget } from "../../../translate/domain/build-target.js"; import type { NativeMarketplaceRegistration, NativeRegistrations, } from "../../domain/manifest/native-registrations.js"; -import type { Manifest } from "../../domain/manifest.js"; +import { Manifest } from "../../domain/manifest.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../../domain/ports/user-source-references.js"; +import { + inspectNativeMarketplaceSource, + sameNativeMarketplaceSource, +} from "../ownership/native-marketplace-source-proof.js"; import type { EnsureBuiltMarketplace } from "../shared/ensure-built-marketplace-use-case.js"; import { hostMarketplaceSourceConflict, @@ -80,12 +99,24 @@ export interface MarketplaceSyncSettingsOptions { interface ActivationOutcome { marketplaces: readonly NativeMarketplaceRegistration[]; + preservedMarketplaces: readonly NativeMarketplaceRegistration[]; + catalogClaims: readonly NativeMarketplaceRegistration[]; pluginRefs: readonly string[]; /** A marketplace whose build failed was warned about and left unregistered this run — the * host's own registration for it is wherever it was before. */ buildFailed: boolean; } +interface NativeCatalogProof { + readonly canonical: boolean; + readonly hostReadable: boolean; + readonly sourceStatus: "absent" | "owned" | "unproven"; + readonly source?: NativeMarketplaceSource; + readonly sourceReason?: string; + readonly foreignRef?: string; + readonly unreadable?: string; +} + export interface MarketplaceSyncSettingsResult { /** Tools whose own CLI actually ran, whether or not every step inside it succeeded. */ activated: readonly ToolId[]; @@ -143,10 +174,28 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { * Absent keeps the silent no-op instead of guessing what to register. */ private readonly marketplaceRegisterFrameworkUseCase?: MarketplaceRegisterFramework, private readonly userSourceReferences?: UserSourceReferences, - private readonly currentVersionProvider?: VersionReader + private readonly currentVersionProvider?: VersionReader, + private readonly hostPluginRegistries: ReadonlyMap< + AiToolId, + HostPluginRegistryReader + > = new Map(), + private readonly userManifestRepo?: ManifestRepository, + private readonly nativeSources: ReadonlyMap = new Map() ) {} async execute(options: MarketplaceSyncSettingsOptions): Promise { + if ( + (options.scope ?? "project") === "project" && + this.userManifestRepo?.withExclusiveAccess !== undefined + ) { + return this.userManifestRepo.withExclusiveAccess(() => this.executeLocked(options)); + } + return this.executeLocked(options); + } + + private async executeLocked( + options: MarketplaceSyncSettingsOptions + ): Promise { const { projectRoot } = options; const manifestRepo = options.manifestRepo ?? this.manifestRepo; const scope = options.scope ?? "project"; @@ -163,14 +212,16 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ? recreatedMarketplaces : recreatedMarketplaces.filter((m) => options.marketplaceNames?.includes(m.name)); if (marketplaces.length === 0) return EMPTY_RESULT; - // A user-scope run has no project-scope manifest for a later `clean` to decrement this - // claim from. - if (scope !== "user") await this.recordSharedSourceReference(projectRoot, marketplaces); const toolIds = this.selectToolIds(manifest, options.toolIds); let anyToolUpdated = false; // A user-scope run lands nothing under `projectRoot`, so no project settings file mirrors it. if (scope === "project") { for (const toolId of toolIds) { + if ( + resolvePluginsCapability(toolId)?.marketplaceSettings + ?.declarativePluginActivationRequiresNativeProof === true + ) + continue; if (await this.syncTool(toolId, projectRoot, manifest, marketplaces)) anyToolUpdated = true; } } @@ -182,6 +233,35 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolIds, scope ); + let declarativeChanged = false; + if (scope === "project") { + for (const [toolId, outcome] of activation.outcomes) { + const settings = resolvePluginsCapability(toolId)?.marketplaceSettings; + if (settings?.declarativePluginActivationRequiresNativeProof !== true) continue; + const hostNames = new Set(outcome.marketplaces.map((entry) => entry.hostName)); + const provenRefs = outcome.pluginRefs.filter((ref) => + [...hostNames].some((hostName) => ref.endsWith(`@${hostName}`)) + ); + if ( + provenRefs.length > 0 && + (await this.syncEnabledPluginsFile( + toolId, + projectRoot, + manifest, + marketplaces, + settings, + provenRefs + )) + ) + declarativeChanged = true; + } + } + if (declarativeChanged) await manifestRepo.save(manifest); + // A refused or unproven host registration cannot establish a new shared-source claim. + // User-scope runs have no project manifest for a later `clean` to decrement it. + if (scope !== "user") { + await this.recordSharedSourceReference(projectRoot, marketplaces, activation.outcomes); + } const wroteHashes = await this.recordWhatActivationWrote(projectRoot, manifest, [ ...activation.outcomes.keys(), ]); @@ -191,6 +271,13 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { options.marketplaceNames !== undefined ); if (wroteHashes || wroteRegistrations) await manifestRepo.save(manifest); + await this.recordMachinePluginClaims( + manifest, + activation.outcomes, + marketplaces, + scope, + scope === "project" ? projectRoot : undefined + ); if (options.recreateFrameworkIfMissing === true && scope === "project") { await this.purgeStaleProjectCache(projectRoot, marketplaces, activation); } @@ -266,18 +353,24 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { } /** - * Refreshed on every run that finds the shared source registered, not only the one that had to - * recreate it: this project's own reference is still missing the first time its `sync` runs here. + * Refreshed only after native activation proves that the host uses the shared source. */ private async recordSharedSourceReference( projectRoot: string, - marketplaces: readonly Marketplace[] + marketplaces: readonly Marketplace[], + outcomes: ReadonlyMap ): Promise { if (this.userSourceReferences === undefined || this.currentVersionProvider === undefined) { return; } const framework = marketplaces.find((m) => frameworkSourceIsShared(m.name, m.scope)); if (framework === undefined) return; + if ( + ![...outcomes.values()].some((outcome) => + outcome.marketplaces.some((registration) => registration.alias === framework.name) + ) + ) + return; await this.recordReferenceForRoot(projectRoot); } @@ -358,8 +451,9 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { const binary = this.nativeActivationBinary(toolId); if (binary === undefined) continue; const existing = manifest.getNativeRegistrations(toolId); - const touchedAliases = new Set(outcome.marketplaces.map((m) => m.alias)); - const touchedHostNames = new Set(outcome.marketplaces.map((m) => m.hostName)); + const projected = [...outcome.marketplaces, ...outcome.preservedMarketplaces]; + const touchedAliases = new Set(projected.map((m) => m.alias)); + const touchedHostNames = new Set(projected.map((m) => m.hostName)); const retainedMarketplaces = narrowed ? (existing?.marketplaces ?? []).filter((m) => !touchedAliases.has(m.alias)) : []; @@ -372,10 +466,15 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ) ) : []; + const preservedHostNames = new Set(outcome.preservedMarketplaces.map((m) => m.hostName)); + const preservedRefs = (existing?.pluginRefs ?? []).filter((ref) => + [...preservedHostNames].some((hostName) => ref.endsWith(`@${hostName}`)) + ); const registrations: NativeRegistrations = { binary, - marketplaces: [...retainedMarketplaces, ...outcome.marketplaces], - pluginRefs: [...new Set([...retainedRefs, ...outcome.pluginRefs])], + marketplaces: [...retainedMarketplaces, ...projected], + pluginRefs: [...new Set([...retainedRefs, ...preservedRefs, ...outcome.pluginRefs])], + ...(existing?.pluginClaims === undefined ? {} : { pluginClaims: existing.pluginClaims }), }; if (nativeRegistrationsEqual(existing, registrations)) continue; manifest.setNativeRegistrations(toolId, registrations); @@ -438,7 +537,11 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { // // Each step is independently best-effort: one failing plugin or marketplace must warn and // let the others through, never abort the whole activation. - const registeredMarketplaces: NativeMarketplaceRegistration[] = []; + const ownMarketplaces: NativeMarketplaceRegistration[] = []; + const preservedMarketplaces: NativeMarketplaceRegistration[] = []; + const catalogClaims: NativeMarketplaceRegistration[] = []; + const addedHostNames = new Set(); + const recorded = manifest.getNativeRegistrations(toolId)?.marketplaces; let buildFailed = false; for (const marketplace of marketplaces) { const registration = await this.registerMarketplace( @@ -446,30 +549,206 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolId, marketplace, projectRoot, - warnings + warnings, + catalogClaims ); - if (!registration.registered) buildFailed = true; - registeredMarketplaces.push({ alias: marketplace.name, hostName: registration.hostName }); + if (registration.outcome === "build-failed") buildFailed = true; + if (registration.addedThisRun === true) addedHostNames.add(registration.hostName); + const entry = { + alias: marketplace.name, + hostName: registration.hostName, + ...(registration.provenance === undefined ? {} : { provenance: registration.provenance }), + }; + const earlier = recorded?.find((m) => m.alias === marketplace.name); + if (registration.outcome === "registered" && registration.claimable) + ownMarketplaces.push(entry); + if (registration.claimable) catalogClaims.push(entry); + else if (earlier !== undefined) { + const proof = await this.nativeCatalogProof(toolId, earlier.hostName, projectRoot); + if ( + proof.canonical && + proof.hostReadable && + proof.sourceStatus === "owned" && + proof.foreignRef === undefined + ) + ownMarketplaces.push(earlier); + else preservedMarketplaces.push(earlier); + } } if (!activator.enablesPlugins()) - return { marketplaces: registeredMarketplaces, pluginRefs: [], buildFailed }; - this.bestEffort(() => activator.upgradeMarketplaces(), "upgrade marketplaces", warnings); - const hostNameByAlias = new Map(registeredMarketplaces.map((m) => [m.alias, m.hostName])); - const refs = this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias); + return { + marketplaces: ownMarketplaces, + preservedMarketplaces, + catalogClaims, + pluginRefs: [], + buildFailed, + }; + const stillProven: NativeMarketplaceRegistration[] = []; + for (const registration of ownMarketplaces) { + const fresh = catalogClaims.find((claim) => claim.hostName === registration.hostName); + const proof = await this.nativeCatalogProof( + toolId, + registration.hostName, + projectRoot, + fresh + ); + if ( + !proof.canonical || + !proof.hostReadable || + proof.sourceStatus !== "owned" || + proof.foreignRef !== undefined + ) + continue; + stillProven.push(registration); + if (!addedHostNames.has(registration.hostName)) + this.bestEffort( + () => activator.upgradeMarketplaces(registration.hostName), + `upgrade marketplace '${registration.hostName}'`, + warnings + ); + } + const hostNameByAlias = new Map(stillProven.map((m) => [m.alias, m.hostName])); + const { refsToEnable, alreadyOwnedRefs } = await this.refsThisProjectEnables( + toolId, + this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias), + manifest, + projectRoot + ); + const enabledRefs = [...alreadyOwnedRefs]; + for (const ref of refsToEnable) { + if ( + this.bestEffort( + () => activator.enablePlugin(ref, scope), + `enable plugin '${ref}'`, + warnings + ) + ) { + enabledRefs.push(ref); + } + } + return { + marketplaces: ownMarketplaces, + preservedMarketplaces, + catalogClaims, + pluginRefs: enabledRefs, + buildFailed, + }; + } + + private async refsThisProjectEnables( + toolId: ToolId, + refs: string[], + manifest: Manifest, + projectRoot: string + ): Promise<{ refsToEnable: string[]; alreadyOwnedRefs: string[] }> { + const hostRegistry = isAiToolId(toolId) ? this.hostPluginRegistries.get(toolId) : undefined; + if (hostRegistry === undefined) return { refsToEnable: refs, alreadyOwnedRefs: [] }; + const ownRefs = manifest.getNativeRegistrations(toolId)?.pluginRefs; + const onHost = (await hostRegistry.read(projectRoot)).refs; + const machine = await this.userManifestRepo?.load(); + const machineClaims = machine?.getNativeRegistrations(toolId)?.pluginClaims ?? []; + const refsToEnable: string[] = []; + const alreadyOwnedRefs: string[] = []; for (const ref of refs) { - this.bestEffort(() => activator.enablePlugin(ref, scope), `enable plugin '${ref}'`, warnings); + if (onHost?.get(ref)?.enabled !== true) { + refsToEnable.push(ref); + continue; + } + if (machineClaims.some((claim) => claim.ref === ref)) { + alreadyOwnedRefs.push(ref); + continue; + } + if (ownRefs?.includes(ref) === true) { + if (!pluginEnablementIsMachineGlobal(toolId) || this.userManifestRepo === undefined) + alreadyOwnedRefs.push(ref); + continue; + } + this.logger.info( + `${toolId}: '${ref}' was already enabled before this project asked for it — left as it is, and this project's clean will leave it enabled.` + ); } - return { marketplaces: registeredMarketplaces, pluginRefs: refs, buildFailed }; + return { refsToEnable, alreadyOwnedRefs }; } - private bestEffort(action: () => void, label: string, warnings: string[]): void { + private async recordMachinePluginClaims( + project: Manifest, + outcomes: ReadonlyMap, + marketplaces: readonly Marketplace[], + scope: MarketplaceScope, + projectRoot?: string + ): Promise { + if (this.userManifestRepo === undefined) return; + const machine = (await this.userManifestRepo.load()) ?? Manifest.create(); + const root = projectRoot === undefined ? undefined : await this.fs.realpath(projectRoot); + let changed = false; + for (const [toolId, outcome] of outcomes) { + const machineRefs = + scope === "user" || pluginEnablementIsMachineGlobal(toolId) ? outcome.pluginRefs : []; + const machineMarketplaces = outcome.catalogClaims.filter((registration) => + marketplaces.some( + (marketplace) => + marketplace.name === registration.alias && + (marketplace.scope === "user" || pluginEnablementIsMachineGlobal(toolId)) + ) + ); + if (machineRefs.length === 0 && machineMarketplaces.length === 0) continue; + if (!machine.hasTool(toolId)) { + const version = project.getToolVersion(toolId); + if (version === undefined) continue; + machine.addTool(toolId, version, []); + } + const existing = machine.getNativeRegistrations(toolId); + const registered = [...(existing?.marketplaces ?? [])]; + for (const registration of machineMarketplaces) { + const index = registered.findIndex( + (entry) => entry.alias === registration.alias && entry.hostName === registration.hostName + ); + if (index >= 0) { + if ( + registration.provenance === undefined || + (registered[index].provenance !== undefined && + sameNativeMarketplaceSource(registered[index].provenance, registration.provenance)) + ) + continue; + registered[index] = registration; + } else registered.push(registration); + changed = true; + } + const claims = [...(existing?.pluginClaims ?? [])].map((claim) => ({ + ref: claim.ref, + dependents: [...claim.dependents], + })); + for (const ref of machineRefs) { + const claim = claims.find((candidate) => candidate.ref === ref); + if (claim === undefined) { + claims.push({ ref, dependents: root === undefined ? [] : [root] }); + changed = true; + } else if (root !== undefined && !claim.dependents.includes(root)) { + claim.dependents.push(root); + changed = true; + } + } + if (changed) + machine.setNativeRegistrations(toolId, { + binary: existing?.binary ?? this.nativeActivationBinary(toolId) ?? toolId, + marketplaces: registered, + pluginRefs: existing?.pluginRefs ?? [], + pluginClaims: claims, + }); + } + if (changed) await this.userManifestRepo.save(machine); + } + + private bestEffort(action: () => void, label: string, warnings: string[]): boolean { try { action(); + return true; } catch (error) { if (!(error instanceof NativePluginCliError)) throw error; const message = `Native plugin activation — ${label} skipped: ${error.message}`; this.logger.warn(message); warnings.push(message); + return false; } } @@ -494,7 +773,61 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { return refs; } - // Native tools must read the BUILT (transformed) tree, not the raw Claude-format source. + private async nativeCatalogProof( + toolId: ToolId, + hostName: string, + projectRoot: string, + fresh?: NativeMarketplaceRegistration + ): Promise { + if (!isAiToolId(toolId)) + return { canonical: false, hostReadable: false, sourceStatus: "unproven" }; + const machine = await this.userManifestRepo?.load(); + const registrations = machine?.getNativeRegistrations(toolId); + const project = await this.manifestRepo.load(); + const localRegistration = pluginEnablementIsMachineGlobal(toolId) + ? undefined + : project + ?.getNativeRegistrations(toolId) + ?.marketplaces.find((entry) => entry.hostName === hostName); + const canonical = + fresh !== undefined || + registrations?.marketplaces.some((entry) => entry.hostName === hostName) === true || + localRegistration !== undefined; + const registration = fresh ?? + registrations?.marketplaces.find((entry) => entry.hostName === hostName) ?? + localRegistration ?? { + alias: hostName, + hostName, + }; + const sourceProof = await inspectNativeMarketplaceSource( + this.nativeSources.get(toolId), + projectRoot, + registration + ); + const reader = this.hostPluginRegistries.get(toolId); + const base = { + canonical, + sourceStatus: sourceProof.status, + ...(sourceProof.current === undefined ? {} : { source: sourceProof.current }), + ...(sourceProof.reason === undefined ? {} : { sourceReason: sourceProof.reason }), + }; + if (reader === undefined) return { ...base, hostReadable: false }; + const reading = await reader.read(projectRoot); + if (reading.absent === true) return { ...base, hostReadable: true }; + if (reading.refs === undefined) + return { ...base, hostReadable: false, unreadable: reading.unreadable ?? reading.location }; + const claimed = new Set([ + ...(registrations?.pluginClaims ?? []).map((claim) => claim.ref), + ...(pluginEnablementIsMachineGlobal(toolId) + ? [] + : (project?.getNativeRegistrations(toolId)?.pluginRefs ?? [])), + ]); + const foreignRef = [...reading.refs.keys()].find( + (ref) => ref.endsWith(`@${hostName}`) && !claimed.has(ref) + ); + return { ...base, hostReadable: true, ...(foreignRef === undefined ? {} : { foreignRef }) }; + } + // Returns the host's own catalog name, never this project's local alias: a catalog this // project just built and cannot read back is not registered at all (see the throw below). private async registerMarketplace( @@ -502,10 +835,18 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolId: ToolId, marketplace: Marketplace, projectRoot: string, - warnings: string[] - ): Promise<{ hostName: string; registered: boolean }> { + warnings: string[], + freshClaims: readonly NativeMarketplaceRegistration[] + ): Promise<{ + hostName: string; + outcome: "registered" | "refused" | "build-failed"; + claimable: boolean; + addedThisRun?: boolean; + provenance?: NativeMarketplaceSource; + }> { const builtDir = await this.buildForTool(toolId, marketplace, projectRoot); - if (builtDir === null) return { hostName: marketplace.name, registered: false }; + if (builtDir === null) + return { hostName: marketplace.name, outcome: "build-failed", claimable: false }; const requestedIdentity = await readMarketplaceCatalogIdentity(this.fs, toolId, builtDir); if (requestedIdentity === undefined) { throw new UnreadableBuiltCatalogError( @@ -513,6 +854,81 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ); } const hostName = requestedIdentity.name; + const proof = await this.nativeCatalogProof( + toolId, + hostName, + projectRoot, + freshClaims.find((claim) => claim.hostName === hostName) + ); + if ( + proof.sourceStatus === "unproven" || + (proof.sourceStatus === "owned" && proof.source === undefined) + ) { + if (isAiToolId(toolId) && nativeActivationOf(toolId)?.marketplaceRegistry !== undefined) { + const existing = await this.hostMarketplaceRegistries.get(toolId)?.read(); + if (existing?.entries?.has(hostName) === true) + await this.guardAgainstConflict( + toolId, + builtDir, + requestedIdentity, + marketplace, + projectRoot, + warnings + ); + } + const message = `${toolId}: catalogue '${hostName}' host source unproven (${proof.sourceReason ?? "unknown"}); registration left untouched.`; + this.logger.warn(message); + warnings.push(message); + return { hostName, outcome: "refused", claimable: false }; + } + const hostMarketplaceReading = + isAiToolId(toolId) && nativeActivationOf(toolId)?.marketplaceRegistry !== undefined + ? await this.hostMarketplaceRegistries.get(toolId)?.read() + : undefined; + if (hostMarketplaceReading?.entries?.has(hostName) === true && !proof.canonical) { + await this.guardAgainstConflict( + toolId, + builtDir, + requestedIdentity, + marketplace, + projectRoot, + warnings + ); + const message = `${toolId}: catalogue '${hostName}' is already registered on the host without a canonical AIDD claim; same-name collision left untouched.`; + this.logger.warn(message); + warnings.push(message); + return { hostName, outcome: "refused", claimable: false }; + } + if ( + hostMarketplaceReading !== undefined && + hostMarketplaceReading.entries === undefined && + hostMarketplaceReading.absent !== true + ) { + const message = `${toolId}: catalogue '${hostName}' host registry is unreadable; ownership unproven and registration left untouched.`; + this.logger.warn(message); + warnings.push(message); + return { hostName, outcome: "refused", claimable: false }; + } + if (!proof.hostReadable || proof.foreignRef !== undefined || proof.unreadable !== undefined) { + const message = + proof.foreignRef === undefined + ? `${toolId}: catalogue '${hostName}' collides with unreadable host plugin registry (${proof.unreadable}); registration left untouched.` + : `${toolId}: catalogue '${hostName}' carries foreign host ref '${proof.foreignRef}'; registration left untouched.`; + this.logger.warn(message); + warnings.push(message); + return { hostName, outcome: "refused", claimable: false }; + } + const requestedSource = await this.fs.realpath(builtDir).catch(() => builtDir); + if ( + proof.sourceStatus === "owned" && + proof.source?.source !== undefined && + samePath(proof.source.source, requestedSource) && + (proof.source.kind === "registry" || + (proof.source.kind === "effective-list" && + samePath(proof.source.root, requestedSource) && + proof.source.sourceType === "local")) + ) + return { hostName, outcome: "registered", claimable: true, provenance: proof.source }; const decision = await this.guardAgainstConflict( toolId, builtDir, @@ -523,14 +939,75 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ); // A "skip" is a host already following a newer shared build, never this project's own // pre-migration cache, so it counts as registered. - if (decision === "skip") return { hostName, registered: true }; + if (decision === "skip") + return { + hostName, + outcome: "registered", + claimable: proof.sourceStatus === "owned", + provenance: proof.source, + }; try { activator.addMarketplace(builtDir, marketplace.scope); } catch (error) { if (!(error instanceof NativePluginCliError)) throw error; - this.reclaimOrReport(toolId, activator, marketplace, hostName, builtDir, error, warnings); + const reclaimed = await this.reclaimOrReport( + toolId, + activator, + marketplace, + hostName, + builtDir, + projectRoot, + error, + warnings + ); + if (!reclaimed) return { hostName, outcome: "refused", claimable: false }; + const afterReclaim = await this.proveAddedSource( + toolId, + hostName, + builtDir, + projectRoot, + warnings + ); + return { + hostName, + outcome: afterReclaim === undefined ? "refused" : "registered", + claimable: afterReclaim !== undefined, + addedThisRun: afterReclaim !== undefined, + provenance: afterReclaim, + }; } - return { hostName, registered: true }; + const afterAdd = await this.proveAddedSource(toolId, hostName, builtDir, projectRoot, warnings); + return { + hostName, + outcome: afterAdd === undefined ? "refused" : "registered", + claimable: afterAdd !== undefined, + addedThisRun: afterAdd !== undefined, + provenance: afterAdd, + }; + } + + private async proveAddedSource( + toolId: ToolId, + hostName: string, + builtDir: string, + projectRoot: string, + warnings: string[] + ): Promise { + if (!isAiToolId(toolId)) return undefined; + const reading = await this.nativeSources.get(toolId)?.read(projectRoot); + const current = reading?.entries?.get(hostName); + const expected = await this.fs.realpath(builtDir).catch(() => builtDir); + const sourceMatches = + current?.source === expected && + (current.kind === "registry" || + (current.kind === "effective-list" && + current.sourceType === "local" && + current.root === expected)); + if (sourceMatches) return current; + const message = `${toolId}: catalogue '${hostName}' add returned but effective host source could not be proven as '${expected}'; no AIDD claim or enablement recorded. Reconcile manually.`; + this.logger.warn(message); + warnings.push(message); + return undefined; } /** @@ -574,29 +1051,23 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { `Marketplace '${check.name}' is already registered from a different catalog: ` + `${check.registeredSource} differs from the one requested, ${check.requestedSource} ` + `— plugins ${describePluginDiff(diff)}, per ${check.location}. ` + - `Run \`claude plugin marketplace remove ${check.name}\`, then \`aidd sync\` again ` + - `to re-register it for this project.` + `Do not remove a catalogue another user or project may depend on. ` + + `Reconcile the host source manually before retrying \`aidd sync\`.` ); } /** * A host already following a newer build is never written backward — warned, not thrown, so a * caller iterating several tools still proceeds. A host tracking *another* project's - * pre-migration cache is no refusal: the repoint completes that migration and its claim is - * recorded alongside this project's, but only once that root is proven to still exist — - * `resolveProjectRootForReferences` falls back to the path as given on `ENOENT`. + * pre-migration cache can proceed only through the separate host-source proof; discovering a + * path here never creates a shared-source claim before registration succeeds. */ private async decideOnDrift( toolId: ToolId, found: MarketplaceSourceDriftFound, warnings: string[] ): Promise<"proceed" | "skip"> { - if (found.drift.kind === "unmigrated-foreign-project-source") { - if (await this.fs.fileExists(found.drift.projectRoot)) { - await this.recordReferenceForRoot(found.drift.projectRoot); - } - return "proceed"; - } + if (found.drift.kind === "unmigrated-foreign-project-source") return "proceed"; if (found.drift.kind !== "version-behind") return "proceed"; const { registeredVersion, requestedVersion } = found.drift; const message = @@ -609,45 +1080,86 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { return "skip"; } - // `add` refused, which for a global registry means the name is already held. A registration that - // still resolves belongs to a live project and taking it would break that project; one whose - // source is gone belongs to nobody. `hostName`, never `marketplace.name`, drives every host-facing - // call below: the alias would answer "dead" for a live registration whenever the two differ. The - // reserved framework name at `"user"` scope is reclaimed on any refusal, not only a proven-dead - // one — codex answers `"unknown"` for every name, copilot `"live"` — safe only because every - // registration under that name is this CLI's own packaged catalog. - private reclaimOrReport( + // Reclaim only a dead Claude registration whose current source is recognisably AIDD-owned. + // `hostName`, never the project's alias, names every host-facing call. + private async reclaimOrReport( toolId: ToolId, activator: NativePluginActivator, marketplace: Marketplace, hostName: string, builtDir: string, + projectRoot: string, addError: NativePluginCliError, warnings: string[] - ): void { - const state = activator.registrationState(hostName); - const isUnguardedFrameworkMarketplace = - marketplace.name === FRAMEWORK_MARKETPLACE_NAME && - marketplace.scope === "user" && - (!isAiToolId(toolId) || nativeActivationOf(toolId)?.marketplaceRegistry === undefined); - if (state !== "dead" && !isUnguardedFrameworkMarketplace) { + ): Promise { + const proof = await this.nativeCatalogProof(toolId, hostName, projectRoot); + if ( + !proof.canonical || + !proof.hostReadable || + proof.sourceStatus !== "owned" || + proof.foreignRef !== undefined + ) { + const message = + `Native plugin activation — catalogue '${hostName}' collides with unproven AIDD ownership` + + `${proof.foreignRef === undefined ? "" : ` and foreign ref '${proof.foreignRef}'`}; host registration left untouched. ${addError.message}`; + this.logger.warn(message); + warnings.push(message); + return false; + } + const registeredSource = isAiToolId(toolId) + ? (await this.hostMarketplaceRegistries.get(toolId)?.read())?.entries?.get(hostName) + : undefined; + const source = + registeredSource === undefined + ? undefined + : await this.fs.realpath(registeredSource).catch(() => registeredSource); + const projectSource = + source === undefined + ? undefined + : parseBuiltMarketplaceDir( + await this.fs.realpath(projectRoot).catch(() => projectRoot), + source + ); + const userRoot = this.userCacheRoot(); + const userSource = + source === undefined || userRoot === "" + ? undefined + : parseUserBuiltMarketplaceDir( + await this.fs.realpath(userRoot).catch(() => userRoot), + source + ); + const provenSource = [projectSource, userSource].some( + (location) => + location !== undefined && + samePathSegment(location.marketplaceName, marketplace.name) && + samePathSegment(location.target, toolId) + ); + if (!provenSource) { + const message = + `Native plugin activation — catalogue '${hostName}' host catalogue source is unproven; ` + + `host registration left untouched. ${addError.message}`; + this.logger.warn(message); + warnings.push(message); + return false; + } + if (activator.registrationState(hostName) !== "dead") { const message = `Native plugin activation — register marketplace '${hostName}' skipped: ${addError.message}`; this.logger.warn(message); warnings.push(message); - return; + return false; } - const reclaimMessage = - state === "dead" - ? `Marketplace '${hostName}' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back.` - : `Marketplace '${hostName}' is registered from a different source and ${toolId} refuses to overwrite it in place; removing and re-registering it from the shared, machine-scope build. Plugins installed from it are removed and the ones this CLI manages are put back.`; + const reclaimMessage = `Marketplace '${hostName}' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back.`; this.logger.warn(reclaimMessage); warnings.push(reclaimMessage); - this.bestEffort( - () => activator.removeMarketplace(hostName, marketplace.scope, { force: true }), - `unregister stale marketplace '${hostName}'`, - warnings - ); - this.bestEffort( + if ( + !this.bestEffort( + () => activator.removeMarketplace(hostName, marketplace.scope, { force: true }), + `unregister stale marketplace '${hostName}'`, + warnings + ) + ) + return false; + return this.bestEffort( () => activator.addMarketplace(builtDir, marketplace.scope), `register marketplace '${hostName}'`, warnings @@ -759,11 +1271,13 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { projectRoot: string, manifest: Manifest, marketplaces: readonly Marketplace[], - settings: MarketplaceSettings + settings: MarketplaceSettings, + provenRefs?: readonly string[] ): Promise { const pluginsPath = resolve(projectRoot, settings.settingsPath); const json = await this.loadSettings(pluginsPath); - if (!this.mergeEnabledPlugins(json, settings, toolId, manifest, marketplaces)) return false; + if (!this.mergeEnabledPlugins(json, settings, toolId, manifest, marketplaces, provenRefs)) + return false; const content = JSON.stringify(json, null, 2); await this.fs.writeFile(pluginsPath, content); manifest.updateTrackedFileHash(toolId, settings.settingsPath, this.hasher.hash(content)); @@ -775,12 +1289,19 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { settings: MarketplaceSettings, toolId: ToolId, manifest: Manifest, - marketplaces: readonly Marketplace[] + marketplaces: readonly Marketplace[], + provenRefs?: readonly string[] ): boolean { const pluginsKey = settings.enabledPluginsKey; if (pluginsKey == null) return false; const existing = this.existingRecord(json, pluginsKey); const toAdd: Record = {}; + if (provenRefs !== undefined) { + for (const ref of provenRefs) if (!(ref in existing)) toAdd[ref] = true; + if (Object.keys(toAdd).length === 0) return false; + json[pluginsKey] = { ...existing, ...toAdd }; + return true; + } const marketplaceByName = new Map(marketplaces.map((m) => [m.name, m])); for (const plugin of manifest.getPlugins(toolId)) { if (plugin.marketplace == null) continue; @@ -850,9 +1371,14 @@ function marketplaceRegistrationsEqual( ): boolean { return ( a.length === b.length && - a.every( - (value, index) => value.alias === b[index]?.alias && value.hostName === b[index]?.hostName - ) + a.every((value, index) => { + const other = b[index]; + if (other === undefined || value.alias !== other.alias || value.hostName !== other.hostName) + return false; + if (value.provenance === undefined || other.provenance === undefined) + return value.provenance === other.provenance; + return sameNativeMarketplaceSource(value.provenance, other.provenance); + }) ); } diff --git a/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts b/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts index f55b4eb18..94a5eec9c 100644 --- a/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts +++ b/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts @@ -15,18 +15,23 @@ import type { AiToolId } from "../../../../../kernel/tool.js"; import type { MarketplaceRegistry } from "../../../../distribution/domain/ports/marketplace-registry.js"; import { frameworkBuildModeFor, + getAiToolConfig, resolvePluginsCapability, } from "../../../../tools/domain/registry.js"; import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js"; import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js"; import type { Manifest } from "../../../domain/manifest.js"; -import { InstalledPlugin } from "../../../domain/plugins/installed-plugin.js"; +import { + InstalledPlugin, + type ProjectHooksProvenance, +} from "../../../domain/plugins/installed-plugin.js"; import { isPluginFileAtDesiredState } from "../../plugin/plugin-helpers.js"; import { resolveBaseDirFromRecord, resolveScopeForInstall, } from "../../plugin/plugin-target-resolution.js"; import type { EnsureBuiltMarketplace } from "../../shared/ensure-built-marketplace-use-case.js"; +import { materializeFlatMcp, refreshTrackedMcpConfigHash } from "./flat-mcp-materializer.js"; import { ModeBFlatMaterializationTranslator } from "./mode-b-flat-materialization-translator.js"; import type { PluginTranslator } from "./plugin-translator.js"; import { ProjectHooksMaterializer } from "./project-hooks-materializer.js"; @@ -51,7 +56,9 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { projectRoot: string, manifest: Manifest, marketplace: string | undefined, - previousMcpEntries: ReadonlyMap = new Map() + previousMcpEntries: ReadonlyMap = new Map(), + userScopeDirTaken = false, + previousProjectHooks?: ProjectHooksProvenance ): Promise<{ skipped: ReadonlySkipList; written?: number }> { const resolved = marketplace === undefined ? null : await this.findMarketplace(marketplace, projectRoot); @@ -63,7 +70,9 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { projectRoot, manifest, marketplace, - previousMcpEntries + previousMcpEntries, + userScopeDirTaken, + previousProjectHooks ); } const mode = frameworkBuildModeFor(toolId); @@ -76,31 +85,54 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { const builtFiles = mode === "flat" ? await this.readFlatFiles(builtDir, dist, toolId) - : await this.readBuiltFiles( - join(builtDir, "plugins", dist.manifest.name), - dist.manifest.name - ); + : await readBuiltUserPluginFiles(this.fs, this.hasher, builtDir, dist.manifest.name); // The built tree still carries a plugin-scoped `hooks/hooks.json` for a capability declaring // `hooksDestination: "project"` — dropped here and materialized through the same project-hooks // side channel the local-source route uses, so both land where the tool's own declaration says. const deliversHooksToProject = resolvePluginsCapability(toolId)?.hooksDestination === "project"; - const hooksSkips = deliversHooksToProject - ? await this.projectHooks.materialize(dist, toolId, projectRoot) - : []; + const hooks = deliversHooksToProject + ? await this.projectHooks.materializeWithProvenance( + dist, + toolId, + projectRoot, + previousProjectHooks + ) + : { skipped: [] as ReadonlySkipList }; const files = deliversHooksToProject ? withoutHooksPrefix(builtFiles, dist.manifest.name) : builtFiles; + const mcp = await materializeFlatMcp( + this.fs, + this.hasher, + dist, + toolId, + projectRoot, + previousMcpEntries + ); + await refreshTrackedMcpConfigHash(this.fs, manifest, toolId, projectRoot, mcp.outputRelPath); const scope = resolveScopeForInstall(toolId); const baseDir = mode === "flat" ? projectRoot : resolveBaseDirFromRecord(scope, toolId, projectRoot, this.homedir); - const written = await this.writeChangedFiles(files, baseDir); + const owned = userScopeDirTaken ? [] : files; + const written = await this.writeChangedFiles(owned, baseDir); manifest.addPlugin( toolId, - InstalledPlugin.fromDistribution(dist, source, files, scope, new Map(), marketplace) + InstalledPlugin.withProjectHooks( + InstalledPlugin.fromDistributionWithMcp( + dist, + source, + owned, + mcp.mcpEntries, + scope, + new Map(), + marketplace + ), + hooks.projectHooks + ) ); - return { skipped: hooksSkips, written }; + return { skipped: [...mcp.mcpSkips, ...hooks.skipped], written }; } // Skips a file already matching the built content on disk, so a no-op restore reports (and @@ -118,26 +150,6 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { return written; } - // Marketplace build emits plugins//; user-scope tools install at - // //, so the manifest relativePath keeps the / prefix. - private async readBuiltFiles(pluginSrc: string, name: string): Promise { - const absPaths = await this.fs.listFilesRecursive(pluginSrc); - return Promise.all( - absPaths.map(async (abs) => { - const rel = posixRelative(pluginSrc, abs); - const content = await this.fs.readFile(abs); - return new InstallationFile({ - // relativePath is always "/"-separated (see withoutHooksPrefix and - // belongsToPlugin below, both string-matching on "/") - node:path's platform - // `join` would answer with "\" on win32, breaking both. - relativePath: posix.join(name, rel), - content, - hash: this.hasher.hash(content), - }); - }) - ); - } - // Flat build emits the whole marketplace into one workspace. Agents are namespaced by // `-`; skills instead nest the whole subtree under `skills//`, since a // skill's own script can `require()` a sibling by relative path, which only keeps resolving while @@ -155,7 +167,7 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { const files: InstallationFile[] = []; for (const abs of absPaths) { const rel = posixRelative(builtDir, abs); - if (!this.belongsToPlugin(rel, name) && !hookPaths.has(rel)) continue; + if (!this.belongsToPlugin(rel, name, toolId) && !hookPaths.has(rel)) continue; const content = await this.fs.readFile(abs); files.push( new InstallationFile({ relativePath: rel, content, hash: this.hasher.hash(content) }) @@ -164,9 +176,10 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { return files; } - private belongsToPlugin(rel: string, name: string): boolean { + private belongsToPlugin(rel: string, name: string, toolId: AiToolId): boolean { const segments = rel.split("/"); - if (segments[0] !== ".opencode" || segments.length < 3) return false; + const toolDirectory = getAiToolConfig(toolId).directory.replace(/\/$/, ""); + if (segments[0] !== toolDirectory || segments.length < 3) return false; // `skills/` nests the whole plugin under one exactly-named segment; every other flat section // hyphen-prefixes the leaf segment. if (segments[1] === "skills") return segments[2] === name; @@ -178,7 +191,7 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { const flatHooksDir = plugins?.flatHooksDir; if (plugins === null || flatHooksDir === null || flatHooksDir === undefined) return new Set(); const name = dist.manifest.name; - return new Set( + const paths = new Set( dist.components.hooks .filter((f) => f.relativePath !== "hooks/hooks.json") .map((f) => @@ -190,6 +203,15 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { ) ) ); + const bridge = plugins.flatHooksBridge; + const hooksJson = dist.components.hooks.find((f) => f.relativePath === "hooks/hooks.json"); + const hasOwnBridge = + bridge !== null && + dist.components.hooks.some((f) => f.relativePath.endsWith(`/${bridge.skipIfSourceHas}`)); + if (bridge !== null && hooksJson !== undefined && !hasOwnBridge) { + if (bridge.generate(hooksJson.content, name) !== null) paths.add(bridge.path(name)); + } + return paths; } private async findMarketplace(name: string, projectRoot: string) { @@ -202,9 +224,33 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { } } +export async function readBuiltUserPluginFiles( + fs: FileReader, + hasher: Hasher, + builtDir: string, + name: string +): Promise { + const pluginSrc = join(builtDir, "plugins", name); + const absPaths = await fs.listFilesRecursive(pluginSrc); + return Promise.all( + absPaths.map(async (abs) => { + const rel = posixRelative(pluginSrc, abs); + const content = await fs.readFile(abs); + return new InstallationFile({ + relativePath: posix.join(name, rel), + content, + hash: hasher.hash(content), + }); + }) + ); +} + // `readBuiltFiles` prefixes every path with `/`, so a built-tree hooks file always reads // `/hooks/`. -function withoutHooksPrefix(files: InstallationFile[], pluginName: string): InstallationFile[] { +export function withoutHooksPrefix( + files: InstallationFile[], + pluginName: string +): InstallationFile[] { const hooksPrefix = `${pluginName}/hooks/`; return files.filter((f) => !f.relativePath.startsWith(hooksPrefix)); } diff --git a/cli/src/contexts/framework/application/framework/translator/flat-mcp-materializer.ts b/cli/src/contexts/framework/application/framework/translator/flat-mcp-materializer.ts new file mode 100644 index 000000000..afbcb0d54 --- /dev/null +++ b/cli/src/contexts/framework/application/framework/translator/flat-mcp-materializer.ts @@ -0,0 +1,89 @@ +import { join } from "node:path"; +import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../../kernel/ports/hasher.js"; +import type { AiToolId } from "../../../../../kernel/tool.js"; +import type { McpCapability } from "../../../../tools/domain/capabilities/mcp-capability.js"; +import { mergeOpencodeMcp } from "../../../../tools/domain/formats/opencode-mcp-merge.js"; +import { getToolConfig, isAiTool } from "../../../../tools/domain/registry.js"; +import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js"; +import type { + PluginTranslationSkip, + ReadonlySkipList, +} from "../../../../translate/domain/plugin-translation-skip.js"; +import type { Manifest } from "../../../domain/manifest.js"; +import { isFrameworkPrimeFlatMcp } from "../../plugin/plugin-target-resolution.js"; + +export async function refreshTrackedMcpConfigHash( + fs: FileReader, + manifest: Manifest, + toolId: AiToolId, + projectRoot: string, + outputRelPath: string | undefined +): Promise { + if (outputRelPath === undefined || !manifest.isFileTracked(outputRelPath)) return; + const outputPath = join(projectRoot, outputRelPath); + if (await fs.fileExists(outputPath)) { + manifest.updateTrackedFileHash(toolId, outputRelPath, await fs.readFileHash(outputPath)); + } +} + +export async function materializeFlatMcp( + fs: FileReader & FileWriter, + hasher: Hasher, + dist: PluginDistribution, + toolId: AiToolId, + projectRoot: string, + previousMcpEntries: ReadonlyMap +): Promise<{ + mcpEntries: ReadonlyMap; + mcpSkips: ReadonlySkipList; + outputRelPath?: string; +}> { + const toolConfig = getToolConfig(toolId); + if (!isAiTool(toolConfig) || dist.components.mcp.length === 0) { + return { mcpEntries: new Map(), mcpSkips: [], outputRelPath: undefined }; + } + const caps = toolConfig.capabilities as Record; + if (!isFrameworkPrimeFlatMcp(caps)) { + return { mcpEntries: new Map(), mcpSkips: [], outputRelPath: undefined }; + } + + const mcpCap = caps.mcp as McpCapability; + const outputRelPath = await mcpCap.resolveOutput(projectRoot, fs); + const outputPath = join(projectRoot, outputRelPath); + const existingContent = await readExistingJson(fs, outputPath); + const transformed = mcpCap.transform(dist.components.mcp[0].content); + const { mergedContent, contributedEntries, collisions, editedPreviousEntries } = mergeOpencodeMcp( + existingContent, + transformed, + previousMcpEntries, + hasher + ); + if (editedPreviousEntries.length > 0) { + throw new Error( + `MCP server '${editedPreviousEntries[0]}' was edited after install; reinstall refused.` + ); + } + if (contributedEntries.size > 0 || previousMcpEntries.size > 0) { + await fs.writeFile(outputPath, mergedContent); + } + const mcpSkips = collisions.map( + (reason): PluginTranslationSkip => ({ + pluginName: dist.manifest.name, + component: "mcp", + toolId, + reason, + }) + ); + return { mcpEntries: contributedEntries, mcpSkips, outputRelPath }; +} + +async function readExistingJson(fs: FileReader, path: string): Promise { + try { + return await fs.readFile(path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } +} diff --git a/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts b/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts index c36e99af3..fbe575bc2 100644 --- a/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts +++ b/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts @@ -1,4 +1,3 @@ -import { join } from "node:path"; import { CursorProjectScopeUnsupportedError } from "../../../../../kernel/errors.js"; import type { InstallationFile } from "../../../../../kernel/file.js"; import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; @@ -6,24 +5,23 @@ import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; import type { Hasher } from "../../../../../kernel/ports/hasher.js"; import type { PluginSource } from "../../../../../kernel/source.js"; import type { AiToolId } from "../../../../../kernel/tool.js"; -import type { McpCapability } from "../../../../tools/domain/capabilities/mcp-capability.js"; import type { PluginsCapability } from "../../../../tools/domain/capabilities/plugins-capability.js"; -import { mergeOpencodeMcp } from "../../../../tools/domain/formats/opencode-mcp-merge.js"; import { getToolConfig, isAiTool } from "../../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../../translate/domain/content-translator.js"; import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js"; -import type { - PluginTranslationSkip, - ReadonlySkipList, -} from "../../../../translate/domain/plugin-translation-skip.js"; +import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js"; import type { Manifest } from "../../../domain/manifest.js"; -import { InstalledPlugin, type PluginScope } from "../../../domain/plugins/installed-plugin.js"; +import { + InstalledPlugin, + type PluginScope, + type ProjectHooksProvenance, +} from "../../../domain/plugins/installed-plugin.js"; import { writePluginFiles } from "../../plugin/plugin-helpers.js"; import { - isFrameworkPrimeFlatMcp, resolveBaseDirFromRecord, resolveScopeForInstall, } from "../../plugin/plugin-target-resolution.js"; +import { materializeFlatMcp, refreshTrackedMcpConfigHash } from "./flat-mcp-materializer.js"; import type { PluginTranslator } from "./plugin-translator.js"; import { ProjectHooksMaterializer, withoutHooks } from "./project-hooks-materializer.js"; @@ -51,20 +49,37 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator { projectRoot: string, manifest: Manifest, marketplace: string | undefined, - previousMcpEntries: ReadonlyMap = new Map() + previousMcpEntries: ReadonlyMap = new Map(), + userScopeDirTaken = false, + previousProjectHooks?: ProjectHooksProvenance ): Promise<{ skipped: ReadonlySkipList }> { const ctx = this.resolveFlatToolContext(toolId, dist, projectRoot); if (ctx === null) return { skipped: [] }; - const mcp = await this.resolveMcp(dist, toolId, projectRoot, previousMcpEntries); - const hooksSkips = await this.projectHooks.materialize(dist, toolId, projectRoot); - const allSkipped: ReadonlySkipList = [...ctx.skipped, ...mcp.mcpSkips, ...hooksSkips]; - if (ctx.files.length === 0 && mcp.mcpEntries.size === 0) return { skipped: allSkipped }; + const mcp = await materializeFlatMcp( + this.fs, + this.hasher, + dist, + toolId, + projectRoot, + previousMcpEntries + ); + await refreshTrackedMcpConfigHash(this.fs, manifest, toolId, projectRoot, mcp.outputRelPath); + const hooks = await this.projectHooks.materializeWithProvenance( + dist, + toolId, + projectRoot, + previousProjectHooks + ); + const allSkipped: ReadonlySkipList = [...ctx.skipped, ...mcp.mcpSkips, ...hooks.skipped]; + if (ctx.files.length === 0 && mcp.mcpEntries.size === 0 && hooks.projectHooks === undefined) + return { skipped: allSkipped }; await this.writeAndRegisterPlugin( dist, toolId, source, - ctx.files, + userScopeDirTaken ? [] : ctx.files, mcp.mcpEntries, + hooks.projectHooks, ctx.componentPaths, marketplace, ctx.baseDir, @@ -102,27 +117,13 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator { return { caps, files, componentPaths, skipped, baseDir, scope }; } - private async resolveMcp( - dist: PluginDistribution, - toolId: AiToolId, - projectRoot: string, - previousMcpEntries: ReadonlyMap - ): Promise<{ mcpEntries: ReadonlyMap; mcpSkips: ReadonlySkipList }> { - const toolConfig = getToolConfig(toolId); - if (!isAiTool(toolConfig)) return { mcpEntries: new Map(), mcpSkips: [] }; - const caps = toolConfig.capabilities as Record; - if (!isFrameworkPrimeFlatMcp(caps) || dist.components.mcp.length === 0) { - return { mcpEntries: new Map(), mcpSkips: [] }; - } - return this.mergeOpencodeMcpEntries(dist, caps, projectRoot, previousMcpEntries, toolId); - } - private async writeAndRegisterPlugin( dist: PluginDistribution, toolId: AiToolId, source: PluginSource, files: InstallationFile[], mcpEntries: ReadonlyMap, + projectHooks: ProjectHooksProvenance | undefined, componentPaths: ReadonlyMap, marketplace: string | undefined, baseDir: string, @@ -139,56 +140,6 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator { componentPaths, marketplace ); - manifest.addPlugin(toolId, plugin); - } - - private async mergeOpencodeMcpEntries( - dist: PluginDistribution, - caps: Record, - projectRoot: string, - previousMcpEntries: ReadonlyMap, - toolId: AiToolId - ): Promise<{ mcpEntries: ReadonlyMap; mcpSkips: ReadonlySkipList }> { - const mcpCap = caps.mcp as McpCapability; - const outputRelPath = await mcpCap.resolveOutput(projectRoot, this.fs); - const outputPath = join(projectRoot, outputRelPath); - const existingContent = await this.readExistingJson(outputPath); - const rawMcp = dist.components.mcp[0].content; - const transformed = mcpCap.transform(rawMcp); - const { mergedContent, contributedEntries, collisions } = mergeOpencodeMcp( - existingContent, - transformed, - previousMcpEntries, - this.hasher - ); - if (contributedEntries.size > 0 || previousMcpEntries.size > 0) { - await this.fs.writeFile(outputPath, mergedContent); - } - const mcpSkips = this.collisionsToSkips(collisions, dist.manifest.name, toolId); - return { mcpEntries: contributedEntries, mcpSkips }; - } - - private collisionsToSkips( - collisions: ReadonlyArray, - pluginName: string, - toolId: AiToolId - ): ReadonlySkipList { - return collisions.map( - (reason): PluginTranslationSkip => ({ - pluginName, - component: "mcp", - toolId, - reason, - }) - ); - } - - private async readExistingJson(path: string): Promise { - try { - return await this.fs.readFile(path); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; - throw err; - } + manifest.addPlugin(toolId, InstalledPlugin.withProjectHooks(plugin, projectHooks)); } } diff --git a/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts b/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts index 6cb29bfcd..b287cb23e 100644 --- a/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts +++ b/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts @@ -4,6 +4,7 @@ import type { PluginTranslationMode } from "../../../../tools/domain/plugin-tran import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js"; import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js"; import type { Manifest } from "../../../domain/manifest.js"; +import type { ProjectHooksProvenance } from "../../../domain/plugins/installed-plugin.js"; /** A translator strategy contract, not a hexagonal port adapter. */ export interface PluginTranslator { @@ -23,6 +24,8 @@ export interface PluginTranslator { projectRoot: string, manifest: Manifest, marketplace: string | undefined, - previousMcpEntries?: ReadonlyMap + previousMcpEntries?: ReadonlyMap, + userScopeDirTaken?: boolean, + previousProjectHooks?: ProjectHooksProvenance ): Promise<{ skipped: ReadonlySkipList; written?: number }>; } diff --git a/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts b/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts index 1a8735048..792d10837 100644 --- a/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts +++ b/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { cursorProjectHooksScriptPath, mergeCursorProjectHooksJson, @@ -15,6 +15,12 @@ import type { PluginTranslationSkip, ReadonlySkipList, } from "../../../../translate/domain/plugin-translation-skip.js"; +import type { ProjectHooksProvenance } from "../../../domain/plugins/installed-plugin.js"; +import { + assertProjectHooksUnchanged, + recordedProjectHookEntries, + removeRecordedProjectHooks, +} from "../../shared/remove-project-hooks.js"; const HOOKS_MANIFEST_PATH = "hooks/hooks.json"; @@ -32,27 +38,53 @@ export class ProjectHooksMaterializer { toolId: AiToolId, projectRoot: string ): Promise { + return (await this.materializeWithProvenance(dist, toolId, projectRoot)).skipped; + } + + async materializeWithProvenance( + dist: PluginDistribution, + toolId: AiToolId, + projectRoot: string, + previous?: ProjectHooksProvenance + ): Promise<{ skipped: ReadonlySkipList; projectHooks?: ProjectHooksProvenance }> { const pluginsCap = resolvePluginsCapability(toolId); - if (pluginsCap === null || pluginsCap.hooksDestination !== "project") return []; + if (pluginsCap === null || pluginsCap.hooksDestination !== "project") return { skipped: [] }; const projectHooksRelativePath = pluginsCap.projectHooksRelativePath; - if (projectHooksRelativePath === null) return []; + if (projectHooksRelativePath === null) return { skipped: [] }; const manifestFile = dist.components.hooks.find((f) => f.relativePath === HOOKS_MANIFEST_PATH); - if (manifestFile === undefined) return []; - const warnings = await this.mergeProjectHooksJson( + if (manifestFile === undefined) { + if (previous !== undefined) { + await removeRecordedProjectHooks( + this.fs, + dist.manifest.name, + previous, + toolId, + projectRoot + ); + } + return { skipped: [] }; + } + await assertProjectHooksUnchanged(this.fs, dist.manifest.name, previous, toolId, projectRoot); + await this.assertScriptsNotUserOwned(dist, projectRoot, previous); + const { warnings, entries } = await this.mergeProjectHooksJson( dist, manifestFile, projectRoot, projectHooksRelativePath ); - await this.writeProjectHooksScripts(dist, projectRoot); - return warnings.map( - (reason): PluginTranslationSkip => ({ - pluginName: dist.manifest.name, - component: "hooks", - toolId, - reason, - }) - ); + const scripts = await this.writeProjectHooksScripts(dist, projectRoot); + await this.removeObsoleteScripts(previous, scripts, projectRoot); + return { + skipped: warnings.map( + (reason): PluginTranslationSkip => ({ + pluginName: dist.manifest.name, + component: "hooks", + toolId, + reason, + }) + ), + projectHooks: { entries, scripts }, + }; } private async mergeProjectHooksJson( @@ -60,7 +92,7 @@ export class ProjectHooksMaterializer { manifestFile: PluginComponentFile, projectRoot: string, projectHooksRelativePath: string - ): Promise { + ): Promise<{ warnings: readonly string[]; entries: ProjectHooksProvenance["entries"] }> { const destPath = join(projectRoot, projectHooksRelativePath); const existing = await this.readExistingJson(destPath); const { content, warnings } = mergeCursorProjectHooksJson( @@ -69,17 +101,55 @@ export class ProjectHooksMaterializer { dist.manifest.name ); await this.fs.writeFile(destPath, content); - return warnings; + return { warnings, entries: recordedProjectHookEntries(content, dist.manifest.name) }; + } + + private async assertScriptsNotUserOwned( + dist: PluginDistribution, + projectRoot: string, + previous?: ProjectHooksProvenance + ): Promise { + for (const file of dist.components.hooks) { + if (file.relativePath === HOOKS_MANIFEST_PATH) continue; + const relativePath = cursorProjectHooksScriptPath(dist.manifest.name, file.relativePath); + if (previous?.scripts.has(relativePath) === true) continue; + if (await this.fs.fileExists(join(projectRoot, relativePath))) { + throw new Error(`Cursor hook script '${relativePath}' is user-owned; install refused.`); + } + } } private async writeProjectHooksScripts( dist: PluginDistribution, projectRoot: string - ): Promise { + ): Promise> { + const scripts = new Map(); for (const file of dist.components.hooks) { if (file.relativePath === HOOKS_MANIFEST_PATH) continue; const dest = cursorProjectHooksScriptPath(dist.manifest.name, file.relativePath); await this.fs.writeFile(join(projectRoot, dest), file.content); + scripts.set(dest, (await this.fs.readFileHash(join(projectRoot, dest))).value); + } + return scripts; + } + + private async removeObsoleteScripts( + previous: ProjectHooksProvenance | undefined, + current: ReadonlyMap, + projectRoot: string + ): Promise { + if (previous === undefined) return; + for (const [relativePath, digest] of previous.scripts) { + if (current.has(relativePath)) continue; + const path = join(projectRoot, relativePath); + if (!(await this.fs.fileExists(path))) continue; + if ((await this.fs.readFileHash(path)).value !== digest) { + throw new Error( + `Cursor hook script '${relativePath}' was edited during reinstall; removal refused.` + ); + } + await this.fs.deleteFile(path); + await this.fs.deleteEmptyDirectories(dirname(path)); } } diff --git a/cli/src/contexts/framework/application/global/restore-all-use-case.ts b/cli/src/contexts/framework/application/global/restore-all-use-case.ts index b64833e77..87f48d1a4 100644 --- a/cli/src/contexts/framework/application/global/restore-all-use-case.ts +++ b/cli/src/contexts/framework/application/global/restore-all-use-case.ts @@ -36,6 +36,16 @@ export class RestoreAllUseCase { if (manifest === null) throw new NoManifestError(); const effectiveFiles = interactive ? await this.promptForFiles(projectRoot) : undefined; + if (effectiveFiles !== undefined && effectiveFiles.length === 0) { + return { + totalRestored: 0, + totalKept: 0, + pluginNamesRestored: [], + errors, + unrestorable: [], + nativeOnlyToolIds: [], + }; + } const version = this.resolveVersion(manifest); const restoreResult = await this.runConfigRestore( projectRoot, @@ -73,12 +83,12 @@ export class RestoreAllUseCase { .filter((d) => d.status === "modified" || d.status === "deleted") .map((d) => d.relativePath) ); - if (driftedFiles.length === 0) return []; + if (driftedFiles.length === 0) return undefined; const selected = await this.prompter.checkbox( "Select files to restore:", driftedFiles.map((f) => ({ name: f, value: f })) ); - return selected.length === 0 ? [] : selected; + return selected; } private async runConfigRestore( diff --git a/cli/src/contexts/framework/application/install/install-config-use-case.ts b/cli/src/contexts/framework/application/install/install-config-use-case.ts index da935fd19..4b40ccbc7 100644 --- a/cli/src/contexts/framework/application/install/install-config-use-case.ts +++ b/cli/src/contexts/framework/application/install/install-config-use-case.ts @@ -8,7 +8,7 @@ import type { Platform } from "../../../../runtime/platform/platform.js"; import { CONFIG_MCP, type ConfigRef } from "../../../tools/domain/capabilities/config-refs.js"; import { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js"; import { SettingsCapability } from "../../../tools/domain/capabilities/settings-capability.js"; -import { transformFor as transformMcpForPlatform } from "../../../tools/domain/mcp-exclusion.js"; +import { transformFor as transformMcpForPlatform } from "../../../tools/domain/mcp-launch-command.js"; import type { ConfigCapability } from "../../domain/config-capability.js"; interface InstallConfigOptions { diff --git a/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts b/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts index d67612fdf..b866fb0e4 100644 --- a/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts +++ b/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts @@ -7,7 +7,9 @@ import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Hasher } from "../../../../kernel/ports/hasher.js"; import type { Logger } from "../../../../kernel/ports/logger.js"; import type { AiToolId } from "../../../../kernel/tool.js"; +import { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js"; import { SettingsCapability } from "../../../tools/domain/capabilities/settings-capability.js"; +import { buildOpencodeFlatConfig } from "../../../tools/domain/formats/opencode-mcp-merge.js"; import type { FileMerger } from "../../../tools/domain/ports/file-merger.js"; import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; import type { Manifest } from "../../domain/manifest.js"; @@ -78,16 +80,58 @@ export class InstallRuntimeConfigUseCase { if (!isAiTool(toolConfig) || !toolConfig.configOutputPaths) return []; const files: InstallationFile[] = []; for (const [fileName, outputPath] of Object.entries(toolConfig.configOutputPaths)) { + const resolvedPath = await this.resolveConfigPath(toolConfig, fileName, outputPath, options); const asset = this.assets.loadConfigAsset(options.toolId, fileName); - const content = typeof asset === "string" ? asset : JSON.stringify(asset, null, 2); - if (await this.isUserOwned(outputPath, options)) continue; + let content = typeof asset === "string" ? asset : JSON.stringify(asset, null, 2); + if (await this.isUserOwned(resolvedPath, options)) continue; + const mcp = (toolConfig.capabilities as Record).mcp; + if ( + mcp instanceof McpCapability && + mcp.params.outputPath === fileName && + mcp.params.format === "json" && + mcp.params.mergeStrategy === "framework-prime" + ) { + content = buildOpencodeFlatConfig( + content, + await this.readExistingConfig(resolvedPath, options.projectRoot), + {} + ); + } files.push( - new InstallationFile({ relativePath: outputPath, content, hash: this.hasher.hash(content) }) + new InstallationFile({ + relativePath: resolvedPath, + content, + hash: this.hasher.hash(content), + }) ); } return files; } + private async resolveConfigPath( + toolConfig: Extract, { kind: "ai" }>, + fileName: string, + outputPath: string, + options: InstallRuntimeConfigOptions + ): Promise { + const caps = toolConfig.capabilities as Record; + const mcp = caps.mcp; + if (!(mcp instanceof McpCapability) || mcp.params.outputPath !== fileName) return outputPath; + return mcp.resolveOutput(options.projectRoot, this.fs); + } + + private async readExistingConfig( + relativePath: string, + projectRoot: string + ): Promise { + try { + return await this.fs.readFile(join(projectRoot, relativePath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + } + private buildStaticSettingsFiles(options: InstallRuntimeConfigOptions): InstallationFile[] { const toolConfig = getToolConfig(options.toolId); if (!isAiTool(toolConfig)) return []; diff --git a/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts b/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts new file mode 100644 index 000000000..5c36dce71 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts @@ -0,0 +1,115 @@ +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../tools/domain/ports/native-marketplace-source-reader.js"; +import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; +import { nativeActivationOf } from "../../../tools/domain/registry.js"; +import type { + NativeMarketplaceRegistration, + NativePluginClaim, + NativeRegistrations, +} from "../../domain/manifest/native-registrations.js"; +import { + assertNoForeignNativeRefs, + inspectNativeMarketplaceSource, +} from "./native-marketplace-source-proof.js"; + +export class NativeHostRegistrationGate { + constructor( + private readonly activators: ReadonlyMap, + private readonly registries: ReadonlyMap, + private readonly sources: ReadonlyMap = new Map() + ) {} + + async requireTargetedUpdate( + toolId: AiToolId, + claim: NativePluginClaim, + projectRoot: string, + registrations?: NativeRegistrations + ): Promise { + const activation = nativeActivationOf(toolId); + if (activation?.updateVerb === undefined) { + throw new Error( + `${toolId} does not support targeted native plugin update for '${claim.ref}'; use marketplace refresh or framework update instead.` + ); + } + const activator = this.activators.get(activation.binary); + const reader = this.registries.get(toolId); + if ( + activator === undefined || + !activator.isAvailable() || + activator.updatePlugin === undefined || + reader === undefined + ) { + throw new Error( + `${toolId}: cannot update AIDD-owned ref '${claim.ref}' without its CLI and readable host registry; canonical claims retained.` + ); + } + if ((await reader.read(projectRoot)).refs?.get(claim.ref)?.enabled !== true) { + throw new Error( + `${toolId}: ref '${claim.ref}' is not provably enabled on the host; update refused.` + ); + } + const registration = registrations?.marketplaces.find((marketplace) => + claim.ref.endsWith(`@${marketplace.hostName}`) + ); + if (registration === undefined) + throw new Error( + `${toolId}: ref '${claim.ref}' has no canonical catalogue source proof; update refused.` + ); + const proof = await inspectNativeMarketplaceSource( + this.sources.get(toolId), + projectRoot, + registration + ); + if (proof.status !== "owned") + throw new Error(proof.reason ?? `${toolId}: catalogue source unproven.`); + return activator; + } + + async planMarketplaceRemoval( + toolId: AiToolId, + registrations: NativeRegistrations, + registration: NativeMarketplaceRegistration, + projectRoot: string + ): Promise<{ + activator: NativePluginActivator; + refs: readonly NativePluginClaim[]; + scopes: ReadonlyMap; + }> { + const activator = this.activators.get(registrations.binary); + const reader = this.registries.get(toolId); + if (activator === undefined || !activator.isAvailable() || reader === undefined) { + throw new Error( + `Cannot prove/remove AIDD-owned catalogue '${registration.hostName}': ${registrations.binary} CLI or host registry unavailable.` + ); + } + const proof = await inspectNativeMarketplaceSource( + this.sources.get(toolId), + projectRoot, + registration + ); + if (proof.status !== "owned") + throw new Error(proof.reason ?? `${toolId}: catalogue source unproven.`); + const owned = new Set((registrations.pluginClaims ?? []).map((claim) => claim.ref)); + const hostRefs = await assertNoForeignNativeRefs( + reader, + registration.hostName, + owned, + projectRoot + ); + const refs = (registrations.pluginClaims ?? []).filter((claim) => + claim.ref.endsWith(`@${registration.hostName}`) + ); + for (const claim of refs) { + if (hostRefs.get(claim.ref)?.enabled !== true) + throw new Error( + `Cannot prove AIDD-owned host ref '${claim.ref}' is still enabled; refusing user-scope removal.` + ); + } + return { + activator, + refs, + scopes: new Map([...hostRefs].map(([ref, state]) => [ref, state.scope])), + }; + } +} diff --git a/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts b/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts new file mode 100644 index 000000000..126784df5 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts @@ -0,0 +1,110 @@ +import type { + HostPluginRegistryEntry, + HostPluginRegistryReader, + HostPluginRegistryReading, +} from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceReader, + NativeMarketplaceSourceReading, +} from "../../../tools/domain/ports/native-marketplace-source-reader.js"; +import type { NativeMarketplaceRegistration } from "../../domain/manifest/native-registrations.js"; + +export interface NativeMarketplaceSourceInspection { + readonly status: "absent" | "owned" | "unproven"; + readonly current?: NativeMarketplaceSource; + readonly reason?: string; +} + +export function sameNativeMarketplaceSource( + recorded: NativeMarketplaceSource, + current: NativeMarketplaceSource +): boolean { + if (recorded.kind !== current.kind || recorded.source !== current.source) return false; + if (recorded.kind === "registry" && current.kind === "registry") return true; + return ( + recorded.kind === "effective-list" && + current.kind === "effective-list" && + recorded.root === current.root && + recorded.sourceType === current.sourceType + ); +} + +export async function inspectNativeMarketplaceSource( + reader: NativeMarketplaceSourceReader | undefined, + projectRoot: string, + registration: NativeMarketplaceRegistration +): Promise { + const name = registration.hostName; + if (reader === undefined) + return { + status: "unproven", + reason: `Catalogue '${name}': host source reader unavailable; reconcile manually.`, + }; + let reading: NativeMarketplaceSourceReading; + try { + reading = await reader.read(projectRoot); + } catch (error) { + return { + status: "unproven", + reason: `Catalogue '${name}': host source read failed (${error instanceof Error ? error.message : String(error)}); reconcile manually.`, + }; + } + if (reading.entries === undefined) + return { + status: "unproven", + reason: `Catalogue '${name}': ${reading.unreadable ?? `host source unreadable at ${reading.location}`}; reconcile manually.`, + }; + if (!reading.entries.has(name)) return { status: "absent" }; + const current = reading.entries.get(name); + if (current === undefined || current === null) + return { + status: "unproven", + reason: `Catalogue '${name}': current host source is unproven; reconcile manually.`, + }; + if (registration.provenance === undefined) + return { + status: "unproven", + reason: `Catalogue '${name}': legacy claim has no source proof; reconcile manually.`, + }; + if (!sameNativeMarketplaceSource(registration.provenance, current)) + return { + status: "unproven", + reason: `Catalogue '${name}': current host source differs from AIDD's recorded source; reconcile manually.`, + }; + return { status: "owned", current }; +} + +/** A source may be AIDD-owned while the host still carries somebody else's plugin from it. */ +export async function assertNoForeignNativeRefs( + reader: HostPluginRegistryReader | undefined, + hostName: string, + ownedRefs: ReadonlySet, + projectRoot: string +): Promise> { + if (reader === undefined) + throw new Error( + `Catalogue '${hostName}': host plugin registry reader unavailable; no host mutation made.` + ); + let reading: HostPluginRegistryReading; + try { + reading = await reader.read(projectRoot); + } catch (error) { + throw new Error( + `Catalogue '${hostName}': host plugin registry read failed (${error instanceof Error ? error.message : String(error)}); no host mutation made.` + ); + } + if (reading.refs === undefined && reading.absent !== true) + throw new Error( + `Catalogue '${hostName}': host plugin registry unreadable (${reading.unreadable ?? reading.location}); no host mutation made.` + ); + const refs = reading.refs ?? new Map(); + const foreign = [...refs.keys()].find( + (ref) => ref.endsWith(`@${hostName}`) && !ownedRefs.has(ref) + ); + if (foreign !== undefined) + throw new Error( + `Catalogue '${hostName}' includes foreign host ref '${foreign}'; no host mutation made.` + ); + return refs; +} diff --git a/cli/src/contexts/framework/application/ownership/native-plugin-ownership.ts b/cli/src/contexts/framework/application/ownership/native-plugin-ownership.ts new file mode 100644 index 000000000..b3fc392e8 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/native-plugin-ownership.ts @@ -0,0 +1,55 @@ +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { ToolId } from "../../../../kernel/tool.js"; +import type { NativePluginClaim } from "../../domain/manifest/native-registrations.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; + +export async function machineNativePluginClaim( + repo: ManifestRepository | undefined, + toolId: ToolId, + ref: string +): Promise { + if (repo === undefined) return undefined; + const machine = await repo.load(); + return machine?.getNativeRegistrations(toolId)?.pluginClaims?.find((claim) => claim.ref === ref); +} + +export async function isMachineOwnedNativeRef( + repo: ManifestRepository | undefined, + toolId: ToolId, + ref: string +): Promise { + return (await machineNativePluginClaim(repo, toolId, ref)) !== undefined; +} + +export async function detachNativePluginRefs( + repo: ManifestRepository | undefined, + fs: FileReader, + projectRoot: string, + refsByTool: ReadonlyMap +): Promise { + if (repo === undefined || refsByTool.size === 0) return; + const action = async () => { + const machine = await repo.load(); + if (machine === null) return; + const root = await fs.realpath(projectRoot); + let changed = false; + for (const [toolId, refs] of refsByTool) { + const registration = machine.getNativeRegistrations(toolId); + if (registration?.pluginClaims === undefined) continue; + const targeted = new Set(refs); + const claims = registration.pluginClaims.map((claim) => { + if (!targeted.has(claim.ref) || !claim.dependents.includes(root)) return claim; + changed = true; + return { + ref: claim.ref, + dependents: claim.dependents.filter((dependent) => dependent !== root), + }; + }); + if (changed) + machine.setNativeRegistrations(toolId, { ...registration, pluginClaims: claims }); + } + if (changed) await repo.save(machine); + }; + if (repo.withExclusiveAccess !== undefined) await repo.withExclusiveAccess(action); + else await action(); +} diff --git a/cli/src/contexts/framework/application/ownership/project-path-boundary.ts b/cli/src/contexts/framework/application/ownership/project-path-boundary.ts new file mode 100644 index 000000000..71b04503c --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/project-path-boundary.ts @@ -0,0 +1,43 @@ +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; + +/** Resolve an existing file or its nearest existing parent before mutating project-local content. */ +export async function assertProjectPathWithinRoot( + fs: FileReader, + projectRoot: string, + path: string +): Promise { + const lexicalRoot = resolve(projectRoot); + const lexicalPath = resolve(path); + if (!strictlyWithin(lexicalRoot, lexicalPath)) { + throw new Error(`'${path}' escapes project root '${projectRoot}'; local mutation refused.`); + } + const canonicalRoot = await fs.realpath(lexicalRoot); + let candidate = lexicalPath; + while (true) { + try { + const canonical = await fs.realpath(candidate); + if (!within(canonicalRoot, canonical)) { + throw new Error( + `'${path}' resolves outside project root '${projectRoot}'; local mutation refused.` + ); + } + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (candidate === lexicalRoot) { + throw new Error(`Project root '${projectRoot}' disappeared; local mutation refused.`); + } + candidate = dirname(candidate); + } + } +} + +function within(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +function strictlyWithin(root: string, path: string): boolean { + return root !== path && within(root, path); +} diff --git a/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts b/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts new file mode 100644 index 000000000..d2bb2b54c --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts @@ -0,0 +1,98 @@ +import { join } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; +import type { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js"; +import { unmergeOpencodeMcp } from "../../../tools/domain/formats/opencode-mcp-merge.js"; +import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; +import { isFrameworkPrimeFlatMcp } from "../plugin/plugin-target-resolution.js"; +import { assertProjectHooksRemovable, removeProjectHooks } from "../shared/remove-project-hooks.js"; +import { detachNativePluginRefs } from "./native-plugin-ownership.js"; +import { assertProjectPathWithinRoot } from "./project-path-boundary.js"; +import { detachUserPlugin } from "./user-plugin-ownership.js"; + +/** Read-only OpenCode MCP proof, shared by Clean and local plugin removals before any detach. */ +export async function assertProjectMcpEntriesRemovable( + fs: FileReader, + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string +): Promise { + await planProjectMcpRemoval(fs, plugin, toolId, projectRoot); +} + +async function planProjectMcpRemoval( + fs: FileReader, + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string +): Promise<{ output: string; content: string } | undefined> { + if (plugin.mcpEntries.size === 0) return undefined; + const config = getToolConfig(toolId); + if (!isAiTool(config)) return undefined; + const caps = config.capabilities as Record; + if (!isFrameworkPrimeFlatMcp(caps)) return undefined; + const mcp = caps.mcp as McpCapability; + const output = join(projectRoot, await mcp.resolveOutput(projectRoot, fs)); + await assertProjectPathWithinRoot(fs, projectRoot, output); + try { + const existing = await fs.readFile(output); + return { output, content: unmergeOpencodeMcp(existing, plugin.mcpEntries) }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +export class ProjectPluginCleanup { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly userManifestRepo?: ManifestRepository + ) {} + + async assertLocalIntegrationRemovable( + toolId: AiToolId, + plugin: InstalledPlugin, + projectRoot: string + ): Promise { + await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, projectRoot); + await assertProjectHooksRemovable(this.fs, plugin, toolId, projectRoot); + } + + async removeLocalIntegration( + toolId: AiToolId, + plugin: InstalledPlugin, + projectRoot: string + ): Promise { + const mcpUpdate = await planProjectMcpRemoval(this.fs, plugin, toolId, projectRoot); + await removeProjectHooks(this.fs, plugin, toolId, projectRoot); + if (mcpUpdate !== undefined) { + await assertProjectPathWithinRoot(this.fs, projectRoot, mcpUpdate.output); + await this.fs.writeFile(mcpUpdate.output, mcpUpdate.content); + } + } + + async deleteLocalFiles( + toolId: AiToolId, + plugin: InstalledPlugin, + projectRoot: string + ): Promise { + await deletePluginFilesForTool(plugin.files, plugin.scope, toolId, projectRoot, this.fs); + } + + async detachClaims( + projectRoot: string, + nativeRefs: ReadonlyMap, + plugins: readonly { toolId: AiToolId; plugin: InstalledPlugin }[] + ): Promise { + await detachNativePluginRefs(this.userManifestRepo, this.fs, projectRoot, nativeRefs); + for (const { toolId, plugin } of plugins) { + if (plugin.scope === "user") { + await detachUserPlugin(this.userManifestRepo, this.fs, toolId, plugin.name, projectRoot); + } + } + } +} diff --git a/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts b/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts new file mode 100644 index 000000000..dfe15769b --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts @@ -0,0 +1,156 @@ +import { + ActiveMachineDependentsError, + InvalidMarketplaceNameError, + MarketplaceNotFoundError, +} from "../../../../kernel/errors.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import { resolveHomeDir } from "../../../../kernel/reading/home-dir.js"; +import { AI_TOOL_IDS } from "../../../../kernel/tool.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + type Marketplace, +} from "../../../distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { + MarketplaceRemoveOptions, + MarketplaceRemoveResult, +} from "../flows/marketplace-remove-use-case.js"; +import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; +import { userScopeFilesSafeToDelete } from "../shared/user-scope-plugin-files.js"; +import type { NativeHostRegistrationGate } from "./native-host-registration-gate.js"; + +export class UserMarketplaceRemoveUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly repo: ManifestRepository, + private readonly registry: MarketplaceRegistry, + private readonly nativeHost: NativeHostRegistrationGate + ) {} + + async execute(options: MarketplaceRemoveOptions): Promise { + if (options.name === FRAMEWORK_MARKETPLACE_NAME) { + throw new InvalidMarketplaceNameError( + `"${FRAMEWORK_MARKETPLACE_NAME}" is shared by every project on this machine and is removed by \`aidd clean --scope user\`, not marketplace remove.` + ); + } + const marketplace = await this.findOrThrow(options.projectRoot, options.name); + const run = () => this.removeLocked(options, marketplace); + return this.repo.withExclusiveAccess === undefined ? run() : this.repo.withExclusiveAccess(run); + } + + private async findOrThrow(projectRoot: string, name: string): Promise { + const found = (await this.registry.list(projectRoot)).find( + (entry) => entry.name === name && entry.scope === "user" + ); + if (found === undefined) throw new MarketplaceNotFoundError(name); + return found; + } + + private async removeLocked( + options: MarketplaceRemoveOptions, + marketplace: Marketplace + ): Promise { + const current = await this.findOrThrow(options.projectRoot, marketplace.name); + if (JSON.stringify(current.source) !== JSON.stringify(marketplace.source)) { + throw new Error( + `User-scope marketplace '${marketplace.name}' changed while waiting for the machine lock; retry against its current source.` + ); + } + const manifest = await this.repo.load(); + if (manifest === null) + throw new Error( + `Cannot prove AIDD ownership of user-scope marketplace '${marketplace.name}': no user manifest.` + ); + const plugins = AI_TOOL_IDS.flatMap((toolId) => + manifest + .getPlugins(toolId) + .filter((plugin) => plugin.scope === "user" && plugin.marketplace === marketplace.name) + .map((plugin) => ({ toolId, plugin })) + ); + const native = AI_TOOL_IDS.flatMap((toolId) => { + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) return []; + return registrations.marketplaces + .filter((registration) => registration.alias === marketplace.name) + .map((registration) => ({ toolId, registrations, registration })); + }); + if (plugins.length === 0 && native.length === 0) { + throw new Error( + `Cannot prove AIDD ownership of user-scope marketplace '${marketplace.name}': no canonical plugin or host catalogue claim.` + ); + } + const nativeRefs = native.flatMap(({ registrations, registration }) => + (registrations.pluginClaims ?? []).filter((claim) => + claim.ref.endsWith(`@${registration.hostName}`) + ) + ); + const dependents = [ + ...new Set([ + ...plugins.flatMap(({ plugin }) => plugin.dependents), + ...nativeRefs.flatMap((claim) => claim.dependents), + ]), + ]; + if (dependents.length > 0) + throw new ActiveMachineDependentsError( + `user-scope marketplace '${marketplace.name}'`, + dependents + ); + const safeFiles = await Promise.all( + plugins.map(async ({ toolId, plugin }) => { + const files = await userScopeFilesSafeToDelete( + this.fs, + { debug() {}, info() {}, warn() {} }, + plugin, + toolId, + resolveHomeDir() + ); + if (files.size !== plugin.files.size) + throw new Error( + `Refusing partial user-scope marketplace removal: '${plugin.name}' has a tracked file outside its boundary.` + ); + return { toolId, plugin, files }; + }) + ); + const nativeCalls = await Promise.all( + native.map(async ({ toolId, registrations, registration }) => ({ + toolId, + registrations, + registration, + ...(await this.nativeHost.planMarketplaceRemoval( + toolId, + registrations, + registration, + options.projectRoot + )), + })) + ); + for (const { registration, activator, refs, scopes } of nativeCalls) { + for (const claim of refs) + activator.uninstallPlugin(claim.ref, scopes.get(claim.ref) ?? "user"); + activator.removeMarketplace(registration.hostName, "user"); + } + for (const { toolId, plugin, files } of safeFiles) { + await deletePluginFilesForTool(files, "user", toolId, options.projectRoot, this.fs, "user"); + manifest.removePlugin(toolId, plugin.name); + } + for (const { toolId, registrations, registration } of nativeCalls) { + manifest.setNativeRegistrations(toolId, { + ...registrations, + marketplaces: registrations.marketplaces.filter( + (entry) => entry.alias !== registration.alias + ), + pluginRefs: registrations.pluginRefs.filter( + (ref) => !ref.endsWith(`@${registration.hostName}`) + ), + pluginClaims: (registrations.pluginClaims ?? []).filter( + (claim) => !claim.ref.endsWith(`@${registration.hostName}`) + ), + }); + } + await this.registry.delete(options.projectRoot, marketplace.name, "user"); + await this.repo.save(manifest); + return { marketplace, removedPluginCount: plugins.length + nativeRefs.length, orphanCount: 0 }; + } +} diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts b/cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts new file mode 100644 index 000000000..03fb25508 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts @@ -0,0 +1,22 @@ +import { join } from "node:path"; +import { PLUGIN_CACHE_SUBDIR } from "../../../../kernel/paths.js"; +import type { PluginFetcher } from "../../../distribution/domain/ports/plugin-fetcher.js"; +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; + +export class UserPluginDistributionLoader { + constructor( + private readonly fetcher: PluginFetcher, + private readonly reader: PluginDistributionReader + ) {} + + async loadLatest(plugin: InstalledPlugin, projectRoot: string): Promise { + const localPath = await this.fetcher.fetch( + plugin.source, + join(projectRoot, PLUGIN_CACHE_SUBDIR), + { forceRefresh: true } + ); + return this.reader.read(localPath); + } +} diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts new file mode 100644 index 000000000..3d4c99d4c --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts @@ -0,0 +1,184 @@ +import { homedir as nodeHomedir } from "node:os"; +import { join } from "node:path"; +import type { InstallationFile } from "../../../../kernel/file.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import { compareSemver } from "../../../../kernel/semver.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import { + frameworkBuildModeFor, + getToolConfig, + resolvePluginsCapability, +} from "../../../tools/domain/registry.js"; +import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; +import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import { + readBuiltUserPluginFiles, + withoutHooksPrefix, +} from "../framework/translator/built-tree-materialization-translator.js"; +import { withoutHooks } from "../framework/translator/project-hooks-materializer.js"; +import { deleteOldFiles, writePluginFiles } from "../plugin/plugin-helpers.js"; +import { resolveBaseDirFromRecord } from "../plugin/plugin-target-resolution.js"; +import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use-case.js"; +import { + assertUserScopeWriteBoundary, + userScopeFilesSafeToDelete, +} from "../shared/user-scope-plugin-files.js"; +import type { UserPluginDistributionLoader } from "./user-plugin-distribution-loader.js"; + +export interface PlannedUserPluginFileUpdate { + readonly plugin: InstalledPlugin; + readonly toolId: AiToolId; + readonly home: string; + readonly baseDir: string; + readonly files: readonly InstallationFile[]; + readonly oldSafe: ReadonlyMap; + readonly next: InstalledPlugin; +} + +export class UserPluginFileUpdater { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly loader: UserPluginDistributionLoader, + private readonly hasher: Hasher, + private readonly builtDeps?: BuiltMaterializationDeps + ) {} + + async update( + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string, + logger: Logger + ): Promise { + const plan = await this.planUpdate(plugin, toolId, projectRoot, logger); + return plan === null ? null : this.applyUpdate(plan, logger); + } + + async planUpdate( + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string, + logger: Logger + ): Promise { + const dist = await this.loader.loadLatest(plugin, projectRoot); + if (compareSemver(dist.manifest.version, plugin.version) <= 0) return null; + const { files, componentPaths } = await this.materializeFiles( + dist, + toolId, + plugin, + projectRoot + ); + const home = (this.builtDeps?.homedir ?? nodeHomedir)(); + const oldSafe = await userScopeFilesSafeToDelete(this.fs, logger, plugin, toolId, home); + if (oldSafe.size !== plugin.files.size) { + throw new Error( + `${toolId}: '${plugin.name}' has unsafe recorded files; update refused without dropping its machine claim.` + ); + } + await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, files, home); + const baseDir = resolveBaseDirFromRecord("user", toolId, projectRoot, () => home); + await this.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files); + return { + plugin, + toolId, + home, + baseDir, + files, + oldSafe, + next: InstalledPlugin.fromDistribution( + dist, + plugin.source, + files, + "user", + componentPaths, + plugin.marketplace + ).withDependents(plugin.dependents), + }; + } + + async applyUpdate(plan: PlannedUserPluginFileUpdate, logger: Logger): Promise { + const oldSafe = await userScopeFilesSafeToDelete( + this.fs, + logger, + plan.plugin, + plan.toolId, + plan.home + ); + if (oldSafe.size !== plan.plugin.files.size || oldSafe.size !== plan.oldSafe.size) { + throw new Error( + `${plan.toolId}: '${plan.plugin.name}' has unsafe recorded files since preflight; update refused before writing.` + ); + } + await assertUserScopeWriteBoundary( + this.fs, + plan.toolId, + plan.plugin.name, + [...plan.files], + plan.home + ); + await this.assertNewFilesDoNotCollide(plan.plugin, plan.toolId, plan.baseDir, plan.files); + await writePluginFiles([...plan.files], plan.baseDir, this.fs); + const newPaths = new Set(plan.files.map((file) => file.relativePath)); + await deleteOldFiles( + new Map([...plan.oldSafe].filter(([path]) => !newPaths.has(path))), + plan.baseDir, + this.fs + ); + return plan.next; + } + + private async assertNewFilesDoNotCollide( + plugin: InstalledPlugin, + toolId: AiToolId, + baseDir: string, + files: readonly InstallationFile[] + ): Promise { + for (const file of files) { + if (plugin.files.has(file.relativePath)) continue; + if (await this.fs.fileExists(join(baseDir, file.relativePath))) { + throw new Error( + `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.` + ); + } + } + } + + private async materializeFiles( + dist: PluginDistribution, + toolId: AiToolId, + plugin: InstalledPlugin, + projectRoot: string + ): Promise<{ files: InstallationFile[]; componentPaths: ReadonlyMap }> { + const marketplace = + plugin.marketplace === undefined + ? undefined + : (await this.builtDeps?.marketplaceRegistry.list(projectRoot))?.find( + (entry) => entry.name === plugin.marketplace + ); + if (marketplace !== undefined && this.builtDeps !== undefined) { + const { builtDir } = await this.builtDeps.ensureBuilt.execute({ + projectRoot, + marketplace, + target: toolId, + mode: frameworkBuildModeFor(toolId), + }); + const built = await readBuiltUserPluginFiles(this.fs, this.hasher, builtDir, plugin.name); + const files = + resolvePluginsCapability(toolId)?.hooksDestination === "project" + ? withoutHooksPrefix(built, plugin.name) + : built; + return { files, componentPaths: new Map() }; + } + const toolConfig = getToolConfig(toolId); + const forGlobalFiles = + resolvePluginsCapability(toolId)?.hooksDestination === "project" ? withoutHooks(dist) : dist; + const translated = new PluginContentTranslator(this.hasher).translateWithComponentPaths( + forGlobalFiles, + toolConfig + ); + return { files: translated.files, componentPaths: translated.componentPaths }; + } +} diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts b/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts new file mode 100644 index 000000000..88ab22c8c --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts @@ -0,0 +1,40 @@ +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; + +/** Release only this project's claim; machine files remain until explicit user cleanup. */ +export async function detachUserPlugin( + repo: ManifestRepository | undefined, + fs: FileReader, + toolId: AiToolId, + name: string, + projectRoot: string +): Promise { + if (repo === undefined) return; + if (repo.withExclusiveAccess !== undefined) { + return repo.withExclusiveAccess(() => + detachUserPluginUnlocked(repo, fs, toolId, name, projectRoot) + ); + } + return detachUserPluginUnlocked(repo, fs, toolId, name, projectRoot); +} + +async function detachUserPluginUnlocked( + repo: ManifestRepository, + fs: FileReader, + toolId: AiToolId, + name: string, + projectRoot: string +): Promise { + const manifest = await repo.load(); + if (manifest === null) return; + const plugin = manifest.getPlugins(toolId).find((p) => p.name === name); + if (plugin === undefined || plugin.scope !== "user") return; + const root = await fs.realpath(projectRoot); + if (!plugin.dependents.includes(root)) return; + manifest.updatePlugin( + toolId, + plugin.withDependents(plugin.dependents.filter((dependent) => dependent !== root)) + ); + await repo.save(manifest); +} diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts new file mode 100644 index 000000000..2978e3fcf --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts @@ -0,0 +1,120 @@ +import { PluginNotFoundError } from "../../../../kernel/errors.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import { Manifest } from "../../domain/manifest.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { resolvePluginToolIds } from "../plugin/plugin-target-resolution.js"; +import type { PluginUpdateOptions } from "../plugin/plugin-update-use-case.js"; +import type { NativeHostRegistrationGate } from "./native-host-registration-gate.js"; +import type { UserPluginFileUpdater } from "./user-plugin-file-updater.js"; + +export class UserPluginUpdateUseCase { + constructor( + private readonly repo: ManifestRepository, + private readonly fileUpdater: UserPluginFileUpdater, + private readonly logger: Logger, + private readonly nativeHost: NativeHostRegistrationGate + ) {} + + async execute(options: PluginUpdateOptions): Promise { + const run = () => this.updateLocked(options); + return this.repo.withExclusiveAccess === undefined ? run() : this.repo.withExclusiveAccess(run); + } + + private async updateLocked(options: PluginUpdateOptions): Promise { + const loaded = await this.repo.load(); + if (loaded === null) + throw new Error( + "No machine manifest: cannot prove AIDD ownership for user-scope plugin update." + ); + const manifest = Manifest.fromJSON(loaded.toJSON()); + const toolIds = resolvePluginToolIds(options.toolIds, manifest); + const targets = toolIds.flatMap((toolId) => + manifest + .getPlugins(toolId) + .filter( + (plugin) => + plugin.scope === "user" && + (options.pluginNames === undefined || options.pluginNames.includes(plugin.name)) + ) + .map((plugin) => ({ toolId, plugin })) + ); + const nativeTargets = toolIds.flatMap((toolId) => + (manifest.getNativeRegistrations(toolId)?.pluginClaims ?? []) + .filter( + (claim) => + options.pluginNames === undefined || + options.pluginNames.some((name) => + name.includes("@") ? claim.ref === name : claim.ref.startsWith(`${name}@`) + ) + ) + .map((claim) => ({ toolId, claim, registrations: manifest.getNativeRegistrations(toolId) })) + ); + if (options.pluginNames !== undefined && targets.length === 0 && nativeTargets.length === 0) { + throw new PluginNotFoundError(options.pluginNames.join(", ")); + } + if ( + options.pluginNames?.some( + (name) => + !name.includes("@") && + nativeTargets.filter(({ claim }) => claim.ref.startsWith(`${name}@`)).length > 1 + ) + ) { + throw new Error( + "Multiple native catalogues match this plugin name; use an exact @ ref for targeted update." + ); + } + const filePlans = await Promise.all( + targets.map(async ({ toolId, plugin }) => ({ + toolId, + plugin, + plan: await this.fileUpdater.planUpdate(plugin, toolId, options.projectRoot, this.logger), + })) + ); + const nativeUpdates = await Promise.all( + nativeTargets.map(async ({ toolId, claim, registrations }) => ({ + toolId, + claim, + activator: await this.nativeHost.requireTargetedUpdate( + toolId, + claim, + options.projectRoot, + registrations + ), + })) + ); + const updated: string[] = []; + for (const { toolId, claim, activator } of nativeUpdates) { + if (claim.dependents.length > 0) + this.logger.warn( + `${toolId}: updating native ref '${claim.ref}' affects ${claim.dependents.join(", ")}; projects must refresh their local integrations afterward.` + ); + if (activator.updatePlugin === undefined) + throw new Error( + `${toolId}: targeted native update became unavailable; canonical claims retained.` + ); + activator.updatePlugin(claim.ref); + updated.push(claim.ref); + } + for (const { toolId, plugin, plan } of filePlans) { + if (plan === null) continue; + if (plugin.dependents.length > 0) + this.logger.warn( + `${toolId}: updating user-scope '${plugin.name}' affects ${plugin.dependents.join(", ")}; each project must refresh its own integration afterward.` + ); + let next: InstalledPlugin; + try { + next = await this.fileUpdater.applyUpdate(plan, this.logger); + } catch (error) { + throw new Error( + `${toolId}: user plugin file update failed after preflight; a native ref may already have been updated and file bytes may be partial. Canonical claim not saved; reconcile manually before retrying.`, + { cause: error } + ); + } + manifest.updatePlugin(toolId, next); + updated.push(plugin.name); + } + if (updated.length > 0) await this.repo.save(manifest); + return updated; + } +} diff --git a/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts index a5ca4087a..f69c8cc8b 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { DuplicatePluginError, MissingPluginMetadataError, + ToolNotInManifestError, VersionMismatchError, } from "../../../../kernel/errors.js"; import type { InstallationFile } from "../../../../kernel/file.js"; @@ -20,15 +21,24 @@ import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; import type { ReadonlySkipList } from "../../../translate/domain/plugin-translation-skip.js"; -import type { Manifest } from "../../domain/manifest.js"; -import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import { Manifest } from "../../domain/manifest.js"; +import { + InstalledPlugin, + type ProjectHooksProvenance, +} from "../../domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; import type { PluginTranslator } from "../framework/translator/plugin-translator.js"; import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js"; +import { assertProjectMcpEntriesRemovable } from "../ownership/project-plugin-cleanup.js"; import type { EnsureBuiltMarketplace } from "../shared/ensure-built-marketplace-use-case.js"; +import { assertProjectHooksRemovable } from "../shared/remove-project-hooks.js"; import { loadPluginManifest, writePluginFiles } from "./plugin-helpers.js"; -import { resolvePluginToolIds, resolveScopeForInstall } from "./plugin-target-resolution.js"; +import { + resolveBaseDirFromRecord, + resolvePluginToolIds, + resolveScopeForInstall, +} from "./plugin-target-resolution.js"; export interface PluginAddOptions { source: PluginSource; @@ -55,18 +65,38 @@ export class PluginAddUseCase implements PluginAdd { private readonly hasher: Hasher, private readonly logger: Logger, private readonly marketplaceRegistry: MarketplaceRegistry, - private readonly ensureBuilt: EnsureBuiltMarketplace + private readonly ensureBuilt: EnsureBuiltMarketplace, + private readonly userManifestRepo: ManifestRepository ) {} async execute(options: PluginAddOptions): Promise { + if (this.userManifestRepo.withExclusiveAccess !== undefined) { + return this.userManifestRepo.withExclusiveAccess(() => this.executeLocked(options)); + } + return this.executeLocked(options); + } + + private async executeLocked(options: PluginAddOptions): Promise { const { source, toolIds, projectRoot, marketplace } = options; - const manifest = await loadPluginManifest(this.manifestRepo); + const manifest = Manifest.fromJSON((await loadPluginManifest(this.manifestRepo)).toJSON()); + const existingMachine = await this.userManifestRepo.load(); + const machine = + existingMachine === null ? Manifest.create() : Manifest.fromJSON(existingMachine.toJSON()); const resolvedToolIds = resolvePluginToolIds(toolIds, manifest); + const installedBefore = pluginsInstalledBefore(manifest, resolvedToolIds); if (marketplace !== undefined && (await this.isGithubMarketplace(marketplace, projectRoot))) { - await this.addGithubMarketplacePlugin(options, resolvedToolIds, manifest); + await this.addGithubMarketplacePlugin(options, resolvedToolIds, manifest, machine); } else { - await this.addLocalPlugin(options, resolvedToolIds, manifest, source, projectRoot); + await this.addLocalPlugin(options, resolvedToolIds, manifest, source, projectRoot, machine); } + const claimed = await this.recordUserScopeOwnership( + manifest, + machine, + resolvedToolIds, + projectRoot, + installedBefore + ); + if (claimed) await this.userManifestRepo.save(machine); await this.manifestRepo.save(manifest); } @@ -79,10 +109,18 @@ export class PluginAddUseCase implements PluginAdd { private async addGithubMarketplacePlugin( options: PluginAddOptions, toolIds: AiToolId[], - manifest: Manifest + manifest: Manifest, + machine: Manifest ): Promise { const { pluginMetadata } = options; if (pluginMetadata === undefined) throw new MissingPluginMetadataError(); + if (options.replace === true) + await this.assertExistingContributionsUnchanged( + pluginMetadata.name, + toolIds, + manifest, + options.projectRoot + ); if (options.replace === true) this.dropExistingPlugin(pluginMetadata.name, toolIds, manifest); else this.validateNoDuplicates(pluginMetadata.name, toolIds, manifest); const adapterMap = this.buildAdapterMap(toolIds); @@ -94,7 +132,8 @@ export class PluginAddUseCase implements PluginAdd { flatToolIds, manifest, options.source, - options.projectRoot + options.projectRoot, + machine ); } await this.registerNativeGithubPlugins(options, nativeToolIds, manifest); @@ -156,14 +195,27 @@ export class PluginAddUseCase implements PluginAdd { resolvedToolIds: AiToolId[], manifest: Manifest, source: PluginSource, - projectRoot: string + projectRoot: string, + machine: Manifest ): Promise { const { marketplace, requiredVersion, replace, pluginMetadata } = options; const read = await this.readDistribution(source, projectRoot); const dist = pluginMetadata === undefined ? read : read.withStrict(pluginMetadata.strict); const pluginName = dist.manifest.name; this.assertPluginVersionMatches(pluginName, dist.manifest.version, requiredVersion); - const { prevMcpMap } = this.prepareForInstall(pluginName, resolvedToolIds, manifest, replace); + if (replace === true) + await this.assertExistingContributionsUnchanged( + pluginName, + resolvedToolIds, + manifest, + projectRoot + ); + const { prevMcpMap, prevHooksMap } = this.prepareForInstall( + pluginName, + resolvedToolIds, + manifest, + replace + ); await this.installPluginForAllTools( dist, resolvedToolIds, @@ -171,23 +223,49 @@ export class PluginAddUseCase implements PluginAdd { projectRoot, manifest, marketplace, - prevMcpMap + prevMcpMap, + prevHooksMap, + machine ); } + private async assertExistingContributionsUnchanged( + pluginName: string, + toolIds: readonly AiToolId[], + manifest: Manifest, + projectRoot: string + ): Promise { + for (const toolId of toolIds) { + const previous = manifest.getPlugins(toolId).find((plugin) => plugin.name === pluginName); + if (previous === undefined) continue; + await assertProjectMcpEntriesRemovable(this.fs, previous, toolId, projectRoot); + await assertProjectHooksRemovable(this.fs, previous, toolId, projectRoot); + } + } + private prepareForInstall( pluginName: string, toolIds: AiToolId[], manifest: Manifest, replace: boolean | undefined - ): { prevMcpMap: Map> } { + ): { + prevMcpMap: Map>; + prevHooksMap: Map; + } { const prevMcpMap = this.collectPreviousMcpEntries(pluginName, toolIds, manifest); + const prevHooksMap = new Map(); + for (const toolId of toolIds) { + const projectHooks = manifest + .getPlugins(toolId) + .find((p) => p.name === pluginName)?.projectHooks; + if (projectHooks !== undefined) prevHooksMap.set(toolId, projectHooks); + } if (replace === true) { this.dropExistingPlugin(pluginName, toolIds, manifest); } else { this.validateNoDuplicates(pluginName, toolIds, manifest); } - return { prevMcpMap }; + return { prevMcpMap, prevHooksMap }; } private async installPluginForAllTools( @@ -197,11 +275,24 @@ export class PluginAddUseCase implements PluginAdd { projectRoot: string, manifest: Manifest, marketplace: string | undefined, - prevMcpMap: Map> + prevMcpMap: Map>, + prevHooksMap: Map, + machine: Manifest ): Promise { const allSkipped: ReadonlySkipList[] = []; const allNotices: ReadonlyNoticeList[] = []; for (const toolId of toolIds) { + const foreignDir = await this.userScopeDirNotInstalledHere( + dist.manifest.name, + toolId, + projectRoot, + machine + ); + if (foreignDir !== undefined) { + this.logger.warn( + `${toolId}: ${foreignDir} was already there and this project did not install it — left as found and not tracked, so this project's clean will not delete it.` + ); + } const prev = prevMcpMap.get(toolId) ?? new Map(); const { skipped, notices } = await this.addPluginForTool( dist, @@ -210,7 +301,10 @@ export class PluginAddUseCase implements PluginAdd { projectRoot, manifest, marketplace, - prev + prev, + foreignDir !== undefined || + machine.getPlugins(toolId).some((p) => p.name === dist.manifest.name), + prevHooksMap.get(toolId) ); allSkipped.push(skipped); allNotices.push(notices); @@ -219,6 +313,54 @@ export class PluginAddUseCase implements PluginAdd { this.emitInstallNotices(allNotices.flat()); } + private async userScopeDirNotInstalledHere( + pluginName: string, + toolId: AiToolId, + projectRoot: string, + machine: Manifest + ): Promise { + if (resolveScopeForInstall(toolId) !== "user") return undefined; + if (machine.getPlugins(toolId).some((p) => p.name === pluginName)) return undefined; + const dir = join( + resolveBaseDirFromRecord("user", toolId, projectRoot, nodeHomedir), + pluginName + ); + const present = await this.fs.listFilesRecursive(dir); + return present.length > 0 ? dir : undefined; + } + + private async recordUserScopeOwnership( + project: Manifest, + machine: Manifest, + toolIds: readonly AiToolId[], + projectRoot: string, + installedBefore: PluginsInstalledBefore + ): Promise { + const root = await this.fs.realpath(projectRoot); + let claimed = false; + for (const toolId of toolIds) { + for (const plugin of project.getPlugins(toolId)) { + if (plugin.scope !== "user") continue; + const owned = machine.getPlugins(toolId).find((p) => p.name === plugin.name); + if (owned === undefined && installedBefore.has(installedKey(toolId, plugin.name))) continue; + if (owned === undefined && plugin.files.size === 0) continue; + if (!machine.hasTool(toolId)) { + const version = project.getToolVersion(toolId); + if (version === undefined) throw new ToolNotInManifestError(toolId); + machine.addTool(toolId, version, []); + } + const canonical = ( + owned ?? InstalledPlugin.withMcpEntries(plugin, new Map()) + ).withDependents([...(owned?.dependents ?? []), root]); + if (owned === undefined) machine.addPlugin(toolId, canonical); + else machine.updatePlugin(toolId, canonical); + project.updatePlugin(toolId, plugin.withFiles(new Map())); + claimed = true; + } + } + return claimed; + } + private collectPreviousMcpEntries( pluginName: string, toolIds: AiToolId[], @@ -264,7 +406,9 @@ export class PluginAddUseCase implements PluginAdd { projectRoot: string, manifest: Manifest, marketplace: string | undefined, - previousMcpEntries: ReadonlyMap = new Map() + previousMcpEntries: ReadonlyMap, + userScopeDirTaken: boolean, + previousProjectHooks?: ProjectHooksProvenance ): Promise<{ skipped: ReadonlySkipList; notices: ReadonlyNoticeList }> { const toolConfig = getToolConfig(toolId); if (!isAiTool(toolConfig)) return { skipped: [], notices: [] }; @@ -277,7 +421,9 @@ export class PluginAddUseCase implements PluginAdd { projectRoot, manifest, marketplace, - previousMcpEntries + previousMcpEntries, + userScopeDirTaken, + previousProjectHooks ); return { ...result, notices: [] }; } @@ -368,3 +514,20 @@ export class PluginAddUseCase implements PluginAdd { }); } } + +type PluginsInstalledBefore = ReadonlySet; + +function pluginsInstalledBefore( + manifest: Manifest, + toolIds: readonly AiToolId[] +): PluginsInstalledBefore { + return new Set( + toolIds.flatMap((toolId) => + manifest.getPlugins(toolId).map((p) => installedKey(toolId, p.name)) + ) + ); +} + +function installedKey(toolId: AiToolId, pluginName: string): string { + return `${toolId}/${pluginName}`; +} diff --git a/cli/src/contexts/framework/application/plugin/plugin-helpers.ts b/cli/src/contexts/framework/application/plugin/plugin-helpers.ts index 6add4784a..49f448b25 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-helpers.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-helpers.ts @@ -50,8 +50,10 @@ export async function deletePluginFilesForTool( scope: PluginScope, toolId: AiToolId, projectRoot: string, - fs: FileWriter + fs: FileWriter, + ownerScope: "project" | "user" = "project" ): Promise { + if (scope === "user" && ownerScope !== "user") return []; const baseDir = resolveBaseDirFromRecord(scope, toolId, projectRoot, nodeHomedir); const deleted: string[] = []; for (const relativePath of files.keys()) { @@ -100,7 +102,10 @@ export async function materializeViaTranslator( plugin.source, projectRoot, manifest, - plugin.marketplace + plugin.marketplace, + plugin.mcpEntries, + false, + plugin.projectHooks ); return written ?? 0; } diff --git a/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts index ae055e6dd..66d5cd22d 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts @@ -1,16 +1,24 @@ import { homedir as nodeHomedir } from "node:os"; import { dirname, join } from "node:path"; -import { NativePluginCliError, PluginNotFoundError } from "../../../../kernel/errors.js"; +import { + ActiveMachineDependentsError, + NativePluginCliError, + PluginNotFoundError, +} from "../../../../kernel/errors.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Logger } from "../../../../kernel/ports/logger.js"; import { resolveHomeDir } from "../../../../kernel/reading/home-dir.js"; import type { MarketplaceScope } from "../../../../kernel/scope.js"; -import type { AiToolId } from "../../../../kernel/tool.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; import type { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js"; import { unmergeOpencodeMcp } from "../../../tools/domain/formats/opencode-mcp-merge.js"; -import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { + HostPluginRegistryReader, + HostPluginRegistryReading, +} from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; import { getToolConfig, @@ -24,8 +32,16 @@ import type { Manifest } from "../../domain/manifest.js"; import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../../domain/ports/user-source-references.js"; +import { inspectNativeMarketplaceSource } from "../ownership/native-marketplace-source-proof.js"; +import { + detachNativePluginRefs, + isMachineOwnedNativeRef, +} from "../ownership/native-plugin-ownership.js"; +import { assertProjectPathWithinRoot } from "../ownership/project-path-boundary.js"; +import { assertProjectMcpEntriesRemovable } from "../ownership/project-plugin-cleanup.js"; +import { detachUserPlugin } from "../ownership/user-plugin-ownership.js"; import { resolveCacheCandidate } from "../shared/purge-declared-cache.js"; -import { removeProjectHooks } from "../shared/remove-project-hooks.js"; +import { assertProjectHooksRemovable, removeProjectHooks } from "../shared/remove-project-hooks.js"; import { resolveUninstallScopeOrder } from "../shared/resolve-uninstall-scope.js"; import { describeGuardedPluginRefMessage, @@ -35,7 +51,8 @@ import { resolveProjectRootForReferences, toleratingUnreadableSourceReferences, } from "../shared/shared-source-reference-support.js"; -import { loadPluginManifest } from "./plugin-helpers.js"; +import { userScopeFilesSafeToDelete } from "../shared/user-scope-plugin-files.js"; +import { deletePluginFilesForTool, loadPluginManifest } from "./plugin-helpers.js"; import { isFrameworkPrimeFlatMcp, resolveBaseDirFromRecord, @@ -46,6 +63,7 @@ export interface PluginRemoveOptions { pluginName: string; toolIds: AiToolId[] | "all"; projectRoot: string; + scope?: "project" | "user"; } export class PluginRemoveUseCase { @@ -69,16 +87,230 @@ export class PluginRemoveUseCase { /** Resolves the scope this project's own registry recorded for `plugin.marketplace` — the one * fact `frameworkSourceIsShared` needs and a plugin record does not carry. Absent treats every * marketplace as not shared. */ - private readonly marketplaceRegistry?: MarketplaceRegistry + private readonly marketplaceRegistry?: MarketplaceRegistry, + private readonly userManifestRepo?: ManifestRepository, + private readonly nativeMarketplaceSources: ReadonlyMap< + AiToolId, + NativeMarketplaceSourceReader + > = new Map() ) {} async execute(options: PluginRemoveOptions): Promise { + if (options.scope === "user") { + if (this.userManifestRepo === undefined) + throw new Error("User manifest repository is required for user-scope plugin removal."); + if (this.userManifestRepo.withExclusiveAccess !== undefined) { + return this.userManifestRepo.withExclusiveAccess(() => this.removeUserPlugin(options)); + } + return this.removeUserPlugin(options); + } const { pluginName, toolIds, projectRoot } = options; const manifest = await loadPluginManifest(this.manifestRepo); const resolvedToolIds = resolvePluginToolIds(toolIds, manifest); + for (const toolId of resolvedToolIds) { + const plugin = manifest.getPlugins(toolId).find((p) => p.name === pluginName); + if (plugin === undefined) continue; + await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, projectRoot); + await assertProjectHooksRemovable(this.fs, plugin, toolId, projectRoot); + await this.assertProjectNativeSource( + plugin, + toolId, + projectRoot, + manifest.getNativeRegistrations(toolId) + ); + } + const userTools = resolvedToolIds.filter((toolId) => + manifest.getPlugins(toolId).some((p) => p.name === pluginName && p.scope === "user") + ); + const nativeRefs = new Map(); + for (const toolId of resolvedToolIds) { + const plugin = manifest.getPlugins(toolId).find((p) => p.name === pluginName); + const registrations = manifest.getNativeRegistrations(toolId); + if (plugin?.marketplace === undefined || registrations === undefined) continue; + const hostName = registrations.marketplaces.find( + (m) => m.alias === plugin.marketplace + )?.hostName; + if (hostName !== undefined) nativeRefs.set(toolId, [`${pluginName}@${hostName}`]); + } const removed = await this.removeFromTools(pluginName, resolvedToolIds, projectRoot, manifest); if (!removed) throw new PluginNotFoundError(pluginName); await this.manifestRepo.save(manifest); + await detachNativePluginRefs(this.userManifestRepo, this.fs, projectRoot, nativeRefs); + for (const toolId of userTools) { + await detachUserPlugin(this.userManifestRepo, this.fs, toolId, pluginName, projectRoot); + } + } + + private async assertProjectNativeSource( + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string, + registrations: NativeRegistrations | undefined + ): Promise { + const activation = resolvePluginsCapability(toolId)?.nativeActivation; + if (activation == null || plugin.marketplace === undefined) return; + const activator = this.activators.get(activation.binary); + if (activator === undefined || !activator.isAvailable()) return; + const registration = registrations?.marketplaces.find( + (entry) => entry.alias === plugin.marketplace + ); + if (registration === undefined) + throw new Error( + `${toolId}: '${plugin.name}' has no recorded native catalogue source for '${plugin.marketplace}'; reconcile manually before removal.` + ); + const ref = `${plugin.name}@${registration.hostName}`; + if ( + pluginEnablementIsMachineGlobal(toolId) && + !(await isMachineOwnedNativeRef(this.userManifestRepo, toolId, ref)) + ) + throw new Error( + `${toolId}: '${ref}' is an unclaimed machine-global host ref; canonical machine claim required before removal.` + ); + if ( + registrations?.pluginRefs.includes(ref) !== true && + this.activators.get(activation.binary)?.enablesPlugins() !== true + ) + throw new Error( + `${toolId}: '${ref}' is not a ref this project enabled; reconcile manually before removal.` + ); + const proof = await inspectNativeMarketplaceSource( + this.nativeMarketplaceSources.get(toolId), + projectRoot, + registration + ); + if (proof.status !== "owned") { + throw new Error( + proof.reason ?? + `${toolId}: catalogue '${registration.hostName}' has unproven host source; reconcile manually.` + ); + } + if (pluginEnablementIsMachineGlobal(toolId) || registrations?.pluginRefs.includes(ref) !== true) + return; + const reader = this.hostPluginRegistries.get(toolId); + if (reader === undefined) + throw new Error( + `${toolId}: '${ref}' has no readable host plugin registry; uninstall refused before project changes.` + ); + let reading: HostPluginRegistryReading; + try { + reading = await reader.read(projectRoot); + } catch (error) { + throw new Error( + `${toolId}: '${ref}' host plugin registry read failed (${error instanceof Error ? error.message : String(error)}); uninstall refused.` + ); + } + const current = reading.refs?.get(ref); + if (current?.enabled !== true || (current.scope !== "project" && current.scope !== "user")) + throw new Error( + `${toolId}: '${ref}' is not provably enabled with an exact host scope (${reading.unreadable ?? reading.location}); uninstall refused.` + ); + } + + private async removeUserPlugin(options: PluginRemoveOptions): Promise { + const repo = this.userManifestRepo; + if (repo === undefined) + throw new Error("User manifest repository is required for user-scope plugin removal."); + const manifest = await loadPluginManifest(repo); + const toolIds = resolvePluginToolIds(options.toolIds, manifest); + const targets = toolIds.flatMap((toolId) => { + const plugin = manifest + .getPlugins(toolId) + .find((p) => p.name === options.pluginName && p.scope === "user"); + return plugin === undefined ? [] : [{ toolId, plugin }]; + }); + const nativeTargets = toolIds.flatMap((toolId) => { + const registrations = manifest.getNativeRegistrations(toolId); + return (registrations?.pluginClaims ?? []) + .filter((claim) => + options.pluginName.includes("@") + ? claim.ref === options.pluginName + : claim.ref.startsWith(`${options.pluginName}@`) + ) + .map((claim) => ({ toolId, registrations: registrations as NativeRegistrations, claim })); + }); + if (targets.length === 0 && nativeTargets.length === 0) + throw new PluginNotFoundError(options.pluginName); + if (!options.pluginName.includes("@") && nativeTargets.length > 1) { + throw new Error( + `Native plugin '${options.pluginName}' has multiple catalogues; use the exact @ ref.` + ); + } + const dependents = [ + ...new Set([ + ...targets.flatMap(({ plugin }) => plugin.dependents), + ...nativeTargets.flatMap(({ claim }) => claim.dependents), + ]), + ]; + if (dependents.length > 0) { + throw new ActiveMachineDependentsError( + `user-scope plugin '${options.pluginName}'`, + dependents + ); + } + const safeFiles = await Promise.all( + targets.map(async ({ toolId, plugin }) => { + const files = await userScopeFilesSafeToDelete( + this.fs, + this.logger, + plugin, + toolId, + resolveHomeDir() + ); + if (files.size !== plugin.files.size) + throw new Error( + `Refusing partial removal of user-scope plugin '${plugin.name}': a tracked file escaped its boundary.` + ); + return { toolId, plugin, files }; + }) + ); + const nativeRemoval = await Promise.all( + nativeTargets.map(async ({ toolId, registrations, claim }) => { + const activator = this.activators.get(registrations.binary); + const reader = this.hostPluginRegistries.get(toolId); + if (activator === undefined || !activator.isAvailable() || reader === undefined) { + throw new Error( + `Cannot prove or remove AIDD-owned native ref '${claim.ref}': ${registrations.binary} CLI or host registry is unavailable.` + ); + } + const onHost = (await reader.read(options.projectRoot)).refs?.get(claim.ref); + if (onHost?.enabled !== true) + throw new Error( + `Cannot prove native ref '${claim.ref}' is still enabled on ${registrations.binary}; no host mutation made.` + ); + const catalogues = registrations.marketplaces.filter((registration) => + claim.ref.endsWith(`@${registration.hostName}`) + ); + if (catalogues.length !== 1) { + throw new Error( + `Native ref '${claim.ref}' has no exact canonical catalogue source proof; removal refused.` + ); + } + const proof = await inspectNativeMarketplaceSource( + this.nativeMarketplaceSources.get(toolId), + options.projectRoot, + catalogues[0] + ); + if (proof.status !== "owned") { + throw new Error(proof.reason ?? `Native ref '${claim.ref}' has unproven host source.`); + } + return { toolId, registrations, claim, activator, scope: onHost.scope ?? "user" }; + }) + ); + for (const target of nativeRemoval) { + target.activator.uninstallPlugin(target.claim.ref, target.scope); + const claims = + target.registrations.pluginClaims?.filter((claim) => claim.ref !== target.claim.ref) ?? []; + manifest.setNativeRegistrations(target.toolId, { + ...target.registrations, + pluginRefs: target.registrations.pluginRefs.filter((ref) => ref !== target.claim.ref), + pluginClaims: claims, + }); + } + for (const { toolId, plugin, files } of safeFiles) { + await deletePluginFilesForTool(files, "user", toolId, options.projectRoot, this.fs, "user"); + manifest.removePlugin(toolId, plugin.name); + } + await repo.save(manifest); } private async removeFromTools( @@ -92,13 +324,36 @@ export class PluginRemoveUseCase { const plugins = manifest.getPlugins(toolId); const plugin = plugins.find((p) => p.name === pluginName); if (plugin === undefined) continue; + const registrations = manifest.getNativeRegistrations(toolId); const baseDir = resolveBaseDirFromRecord(plugin.scope, toolId, projectRoot, nodeHomedir); - const confirmed = await this.removeNativeActivation(plugin, toolId, projectRoot, manifest); + const confirmed = await this.removeNativeActivation( + plugin, + toolId, + projectRoot, + registrations + ); if (confirmed !== undefined) - await this.purgeCachedPlugin(manifest, toolId, plugin, confirmed); - await this.deletePluginFiles(plugin.files, baseDir); + await this.purgeCachedPlugin(registrations, toolId, plugin, confirmed); + if (plugin.scope !== "user") await this.deletePluginFiles(plugin.files, baseDir); await this.removeMcpEntries(plugin, toolId, projectRoot); - await removeProjectHooks(this.fs, pluginName, toolId, projectRoot); + await removeProjectHooks(this.fs, plugin, toolId, projectRoot); + const hostName = registrations?.marketplaces.find( + (m) => m.alias === plugin.marketplace + )?.hostName; + const ref = hostName === undefined ? undefined : `${plugin.name}@${hostName}`; + const canDetachRef = + confirmed === true || + (ref !== undefined && + pluginEnablementIsMachineGlobal(toolId) && + (await isMachineOwnedNativeRef(this.userManifestRepo, toolId, ref))); + if (hostName !== undefined && registrations !== undefined && canDetachRef) { + manifest.setNativeRegistrations(toolId, { + ...registrations, + pluginRefs: registrations.pluginRefs.filter( + (ref) => ref !== `${plugin.name}@${hostName}` + ), + }); + } manifest.removePlugin(toolId, pluginName); removed = true; } @@ -117,14 +372,21 @@ export class PluginRemoveUseCase { plugin: InstalledPlugin, toolId: AiToolId, projectRoot: string, - manifest: Manifest + registrations: NativeRegistrations | undefined ): Promise { const nativeActivation = resolvePluginsCapability(toolId)?.nativeActivation; if (nativeActivation == null || plugin.marketplace === undefined) return undefined; const activator = this.activators.get(nativeActivation.binary); if (activator === undefined) return undefined; + if (!activator.isAvailable()) { + const hostName = this.hostNameFor(registrations, plugin.marketplace) ?? plugin.marketplace; + this.logger.warn( + `${nativeActivation.binary} CLI not found on PATH — '${plugin.name}@${hostName}' was not uninstalled from the host; its ref may remain enabled after local removal.` + ); + return false; + } + await this.assertProjectNativeSource(plugin, toolId, projectRoot, registrations); const alias = plugin.marketplace; - const registrations = manifest.getNativeRegistrations(toolId); const registeredHostName = this.hostNameFor(registrations, alias); if (registeredHostName === undefined && registrations !== undefined) { this.logger.warn( @@ -133,6 +395,20 @@ export class PluginRemoveUseCase { } const hostName = registeredHostName ?? alias; const ref = `${plugin.name}@${hostName}`; + if (await isMachineOwnedNativeRef(this.userManifestRepo, toolId, ref)) { + this.logger.warn( + `${toolId}: '${ref}' is an AIDD-owned machine plugin ref — left enabled; this project's claim detaches after local removal.` + ); + return undefined; + } + if ( + registeredHostName !== undefined && + activator.enablesPlugins() && + registrations?.pluginRefs.includes(ref) !== true + ) { + this.logger.warn(`${toolId}: '${ref}' is not a ref this project enabled — left enabled.`); + return undefined; + } const guardMessage = await this.describeGuardedPluginRef( nativeActivation.binary, toolId, @@ -264,7 +540,7 @@ export class PluginRemoveUseCase { * own `NativeRegistrations`, never the alias, which a host never learns. */ private async purgeCachedPlugin( - manifest: Manifest, + registrations: NativeRegistrations | undefined, toolId: AiToolId, plugin: InstalledPlugin, confirmed: boolean @@ -272,7 +548,7 @@ export class PluginRemoveUseCase { if (plugin.marketplace === undefined) return; const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(resolveHomeDir()); if (cacheRoot === undefined) return; - const hostName = this.hostNameFor(manifest.getNativeRegistrations(toolId), plugin.marketplace); + const hostName = this.hostNameFor(registrations, plugin.marketplace); if (hostName === undefined) return; const label = `${toolId}: cache for '${plugin.name}'`; const candidate = await resolveCacheCandidate( @@ -307,6 +583,7 @@ export class PluginRemoveUseCase { const existing = await this.readExistingJson(outputPath); if (existing === null) return; const updated = unmergeOpencodeMcp(existing, plugin.mcpEntries); + await assertProjectPathWithinRoot(this.fs, projectRoot, outputPath); await this.fs.writeFile(outputPath, updated); } diff --git a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts index 9a79e9eeb..efeaf37f5 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts @@ -1,5 +1,6 @@ import { homedir as nodeHomedir } from "node:os"; import { join } from "node:path"; +import { InvalidPluginScopeError } from "../../../../kernel/errors.js"; import { PLUGIN_CACHE_SUBDIR } from "../../../../kernel/paths.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; @@ -29,6 +30,7 @@ export interface PluginUpdateOptions { pluginNames?: string[]; toolIds: AiToolId[] | "all"; projectRoot: string; + scope?: "project" | "user"; } export class PluginUpdateUseCase { @@ -42,6 +44,8 @@ export class PluginUpdateUseCase { ) {} async execute(options: PluginUpdateOptions): Promise { + if (options.scope === "user") + throw new Error("User-scope plugin update requires UserPluginUpdateUseCase."); const { pluginNames, toolIds, projectRoot } = options; const manifest = await loadPluginManifest(this.manifestRepo); const resolvedToolIds = resolvePluginToolIds(toolIds, manifest); @@ -71,7 +75,7 @@ export class PluginUpdateUseCase { const plugins = manifest.getPlugins(toolId); const targets = pluginNames ? plugins.filter((p) => pluginNames.includes(p.name)) - : [...plugins]; + : plugins.filter((plugin) => plugin.scope !== "user"); const updated: string[] = []; for (const plugin of targets) { const didUpdate = await this.updateOnePlugin(plugin, toolId, projectRoot, cacheDir, manifest); @@ -87,6 +91,7 @@ export class PluginUpdateUseCase { cacheDir: string, manifest: Manifest ): Promise { + if (plugin.scope === "user") throw new InvalidPluginScopeError(toolId, "project", "user"); const localPath = await this.pluginFetcher.fetch(plugin.source, cacheDir, { forceRefresh: true, }); diff --git a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts index 0d1a2053e..9ba14ac66 100644 --- a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts +++ b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts @@ -30,6 +30,16 @@ export class SetupMachineScopeUseCase { async execute(flow: SetupFlow): Promise { const source = await this.setupMarketplaceRegistration.resolveSourceIfNeeded(flow); + if (this.userManifestRepo.withExclusiveAccess !== undefined) { + return this.userManifestRepo.withExclusiveAccess(() => this.executeLocked(flow, source)); + } + return this.executeLocked(flow, source); + } + + private async executeLocked( + flow: SetupFlow, + source: Awaited> + ): Promise { const isNew = await this.initUserManifest(); await this.setupMarketplaceRegistration.registerIfPresent(flow, source); await this.registerUserScopeTools(flow); diff --git a/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts b/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts index 0dbc7c87f..daf70c45f 100644 --- a/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts +++ b/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts @@ -48,6 +48,7 @@ export class ApplyPluginFilesUseCase { ) {} async execute(options: ApplyPluginFilesOptions): Promise { + if (options.plugin.scope === "user") return 0; const localPath = await this.pluginFetcher.fetch(options.plugin.source, options.cacheDir); const dist = await this.pluginDistributionReader.read(localPath); const translator = this.resolveTranslator(options.toolConfig); diff --git a/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts b/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts index 0e5c2476e..46201ea06 100644 --- a/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts +++ b/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts @@ -45,8 +45,8 @@ export async function purgeAllNativeCaches( * running, so containment alone proves the path cannot escape the declared root — never that the * caller still owns what sits inside it. Two proofs, one per declaration: * - * - a profile declaring `marketplaceRegistry` (claude) is reread after the undo: the name gone - * from that registry is the host's own admission nothing there resolves any more; + * - a profile declaring `marketplaceRegistry` (claude) also requires a confirmed host removal, + * then rereads the registry: a name gone says nothing there resolves any more; * - a profile declaring `pluginCacheDir` alone (codex) drives a host that deletes the cached * content itself and leaves only an empty shell — measured. Emptiness proves no data would be * lost, never that this caller emptied it, so `removed` (the host's own confirmation) is @@ -82,6 +82,12 @@ export async function purgeNativeMarketplaceCache( ); return; } + if (!removed) { + logger.warn( + `${binary}: cache for '${hostName}' left in place, its own removal was not confirmed: ${candidate}` + ); + return; + } await purgeOnceRegistryClears(fs, logger, reader, candidate, binary, hostName); } diff --git a/cli/src/contexts/framework/application/shared/remove-project-hooks.ts b/cli/src/contexts/framework/application/shared/remove-project-hooks.ts index cc1e42906..c0674a0e2 100644 --- a/cli/src/contexts/framework/application/shared/remove-project-hooks.ts +++ b/cli/src/contexts/framework/application/shared/remove-project-hooks.ts @@ -1,4 +1,5 @@ -import { dirname, join } from "node:path"; +import { createHash } from "node:crypto"; +import { dirname, join, posix } from "node:path"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { AiToolId } from "../../../../kernel/tool.js"; @@ -7,43 +8,170 @@ import { unmergeCursorProjectHooksJson, } from "../../../tools/domain/formats/cursor-hooks-project-merge.js"; import { resolvePluginsCapability } from "../../../tools/domain/registry.js"; +import type { + InstalledPlugin, + ProjectHooksProvenance, +} from "../../domain/plugins/installed-plugin.js"; +import { assertProjectPathWithinRoot } from "../ownership/project-path-boundary.js"; -/** - * Undoes what `ProjectHooksMaterializer` wrote for one plugin: a tool declaring - * `hooksDestination: "project"` (Cursor) merges a plugin's hooks into the project's own hooks file - * rather than tracking them in `Plugin.files`, so removal needs an unmerge instead of a - * baseDir-relative file delete. Both destinations are recomputed from `pluginName` alone, exactly - * as install computed them, so there is no extra state to keep in sync. - * - * Returns whether anything was actually there to undo. A no-op for a tool declaring no - * project-merged hooks destination. - */ -export async function removeProjectHooks( +type HookEntry = { command: string; [key: string]: unknown }; + +/** Preflight the exact project hook entries and scripts installed by AIDD. Does not mutate. */ +export async function assertProjectHooksUnchanged( + fs: FileReader, + pluginName: string, + provenance: ProjectHooksProvenance | undefined, + toolId: AiToolId, + projectRoot: string +): Promise<{ existing: string | null }> { + const cap = resolvePluginsCapability(toolId); + if (cap?.hooksDestination !== "project" || cap.projectHooksRelativePath === null) { + return { existing: null }; + } + const hooksPath = join(projectRoot, cap.projectHooksRelativePath); + await assertProjectPathWithinRoot(fs, projectRoot, hooksPath); + const existing = await readExistingJson(fs, hooksPath); + const scriptDir = join(projectRoot, cursorProjectHooksScriptDir(pluginName)); + await assertProjectPathWithinRoot(fs, projectRoot, scriptDir); + const scriptsPresent = (await fs.fileExists(scriptDir)) ? await fs.listDirectory(scriptDir) : []; + const currentEntries = existing === null ? [] : contributedEntries(existing, pluginName); + if (provenance === undefined) { + if (currentEntries.length > 0 || scriptsPresent.length > 0) { + throw new Error( + `Cursor project hooks for '${pluginName}' have an unproven legacy install digest; detach refused.` + ); + } + return { existing }; + } + const recorded = provenance.entries.map( + ({ event, command, digest }) => `${event}\u0000${command}\u0000${digest}` + ); + const current = currentEntries.map( + ({ event, entry }) => `${event}\u0000${entry.command}\u0000${digestEntry(entry)}` + ); + if ( + provenance.entries.some(({ digest }) => !/^[0-9a-f]{32}$/.test(digest)) || + recorded.sort().join("\n") !== current.sort().join("\n") + ) { + throw new Error( + `Cursor project hooks for '${pluginName}' were edited after install; detach refused.` + ); + } + for (const [relativePath, digest] of provenance.scripts) { + assertScriptPath(pluginName, relativePath); + const path = join(projectRoot, relativePath); + await assertProjectPathWithinRoot(fs, projectRoot, path); + if (!(await fs.fileExists(path))) continue; + if (!/^[0-9a-f]{32}$/.test(digest)) { + throw new Error( + `Cursor hook script '${relativePath}' has an unproven install digest; detach refused.` + ); + } + if ((await fs.readFileHash(path)).value !== digest) { + throw new Error( + `Cursor hook script '${relativePath}' was edited after install; detach refused.` + ); + } + } + return { existing }; +} + +export async function assertProjectHooksRemovable( + fs: FileReader, + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string +): Promise { + await assertProjectHooksUnchanged(fs, plugin.name, plugin.projectHooks, toolId, projectRoot); +} + +/** Unmerge only verified hook entries and tracked scripts; never delete a directory by name. */ +export async function removeRecordedProjectHooks( fs: FileReader & FileWriter, pluginName: string, + provenance: ProjectHooksProvenance | undefined, toolId: AiToolId, projectRoot: string ): Promise { - const pluginsCap = resolvePluginsCapability(toolId); - if (pluginsCap?.hooksDestination !== "project") return false; - const projectHooksRelativePath = pluginsCap.projectHooksRelativePath; - if (projectHooksRelativePath === null) return false; - const hooksPath = join(projectRoot, projectHooksRelativePath); - const existing = await readExistingJson(fs, hooksPath); - if (existing !== null) { + const cap = resolvePluginsCapability(toolId); + if (cap?.hooksDestination !== "project" || cap.projectHooksRelativePath === null) return false; + const { existing } = await assertProjectHooksUnchanged( + fs, + pluginName, + provenance, + toolId, + projectRoot + ); + const hooksPath = join(projectRoot, cap.projectHooksRelativePath); + const hasEntries = existing !== null && contributedEntries(existing, pluginName).length > 0; + if (hasEntries && existing !== null) { + await assertProjectPathWithinRoot(fs, projectRoot, hooksPath); const unmerged = unmergeCursorProjectHooksJson(existing, pluginName); - // A file this route wrote in the first place — leaving it as an empty shell once - // its last plugin is gone is the same residue clean exists to stop leaving. if (isHooksFileEmpty(unmerged)) await fs.deleteFile(hooksPath); else await fs.writeFile(hooksPath, unmerged); } - const scriptDir = join(projectRoot, cursorProjectHooksScriptDir(pluginName)); - const hadScriptDir = await fs.fileExists(scriptDir); - if (hadScriptDir) { - await fs.deleteDirectory(scriptDir); - await fs.deleteEmptyDirectories(dirname(scriptDir)); + let removedScript = false; + for (const relativePath of provenance?.scripts.keys() ?? []) { + const path = join(projectRoot, relativePath); + if (!(await fs.fileExists(path))) continue; + await assertProjectPathWithinRoot(fs, projectRoot, path); + await fs.deleteFile(path); + await fs.deleteEmptyDirectories(dirname(path)); + removedScript = true; + } + return hasEntries || removedScript; +} + +export async function removeProjectHooks( + fs: FileReader & FileWriter, + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string +): Promise { + return removeRecordedProjectHooks(fs, plugin.name, plugin.projectHooks, toolId, projectRoot); +} + +function contributedEntries( + content: string, + pluginName: string +): readonly { event: string; entry: HookEntry }[] { + const parsed = JSON.parse(content) as { hooks?: Record }; + const marker = cursorProjectHooksScriptDir(pluginName); + return Object.entries(parsed.hooks ?? {}).flatMap(([event, entries]) => + entries + .filter((entry) => typeof entry.command === "string" && entry.command.includes(marker)) + .map((entry) => ({ event, entry })) + ); +} + +/** Hash of one merged hook entry, independent of other plugins' entries. */ +export function digestEntry(entry: HookEntry): string { + return createHash("md5").update(JSON.stringify(entry), "utf-8").digest("hex"); +} + +export function recordedProjectHookEntries( + content: string, + pluginName: string +): ProjectHooksProvenance["entries"] { + return contributedEntries(content, pluginName).map(({ event, entry }) => ({ + event, + command: entry.command, + digest: digestEntry(entry), + })); +} + +function assertScriptPath(pluginName: string, relativePath: string): void { + const prefix = cursorProjectHooksScriptDir(pluginName); + if ( + !relativePath.startsWith(prefix) || + posix.isAbsolute(relativePath) || + relativePath.includes("\\") || + posix.normalize(relativePath) !== relativePath + ) { + throw new Error( + `Cursor hook script path '${relativePath}' has unproven provenance; detach refused.` + ); } - return existing !== null || hadScriptDir; } async function readExistingJson(fs: FileReader, path: string): Promise { diff --git a/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts index 3937f01f5..8a75f4d26 100644 --- a/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts +++ b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts @@ -1,4 +1,5 @@ -import { join } from "node:path"; +import { dirname, join, posix } from "node:path"; +import type { InstallationFile } from "../../../../kernel/file.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { Logger } from "../../../../kernel/ports/logger.js"; import type { AiToolId } from "../../../../kernel/tool.js"; @@ -9,8 +10,8 @@ import { isStrictlyWithinUserScope } from "../../domain/plugins/user-scope-conta /** * The files of a user-scope plugin that are actually safe to delete: safe only once the real, * `realpath`-resolved location still sits strictly inside the tool's own declared user-scope - * directory. A `..` segment a corrupted manifest entry carries, or a plugin directory that became a - * symlink after install, both fail this and are left in place and named. + * directory and its current content still matches the recorded install digest. A `..` segment, + * symlink escape, unreadable file, or user edit refuses a destructive operation before claim detach. */ export async function userScopeFilesSafeToDelete( fs: FileReader, @@ -30,6 +31,17 @@ export async function userScopeFilesSafeToDelete( resolvedCandidate !== null && isStrictlyWithinUserScope(resolvedCandidate, resolvedBoundary) ) { + if (!/^[0-9a-f]{32}$/.test(hash)) { + throw new Error( + `${toolId}: '${plugin.name}' file '${relativePath}' has an unproven install digest; removal refused.` + ); + } + const current = await fs.readFileHash(join(boundary, relativePath)); + if (current.value !== hash) { + throw new Error( + `${toolId}: '${plugin.name}' file '${relativePath}' was edited after install; removal refused.` + ); + } allowed.set(relativePath, hash); continue; } @@ -40,6 +52,60 @@ export async function userScopeFilesSafeToDelete( return allowed; } +/** Refuse a machine update if a new path, existing parent, or plugin directory escapes its declared base. */ +export async function assertUserScopeWriteBoundary( + fs: FileReader, + toolId: AiToolId, + pluginName: string, + files: readonly InstallationFile[], + homedir: string +): Promise { + const boundary = resolvePluginsCapability(toolId)?.userPluginsBaseDir(homedir); + if (boundary === null || boundary === undefined) + throw new Error(`${toolId}: no user plugins directory is declared.`); + const resolvedBoundary = await tryRealpath(fs, boundary); + const pluginDir = join(boundary, pluginName); + const resolvedPluginDir = await tryRealpath(fs, pluginDir); + if ( + resolvedBoundary === null || + resolvedPluginDir === null || + !isStrictlyWithinUserScope(resolvedPluginDir, resolvedBoundary) + ) { + throw new Error( + `${toolId}: '${pluginName}' directory is not safely inside ${boundary}; update refused.` + ); + } + for (const file of files) { + const rel = file.relativePath; + if ( + posix.isAbsolute(rel) || + rel.includes("\\") || + posix.normalize(rel) !== rel || + !rel.startsWith(`${pluginName}/`) + ) { + throw new Error( + `${toolId}: '${pluginName}' update path '${rel}' escapes its plugin directory.` + ); + } + let parent = dirname(join(boundary, rel)); + while (true) { + const resolved = await tryRealpath(fs, parent); + if (resolved !== null) { + if (!isStrictlyWithinUserScope(resolved, resolvedBoundary)) { + throw new Error( + `${toolId}: '${pluginName}' update parent '${parent}' escapes ${boundary}.` + ); + } + break; + } + if (parent === pluginDir) { + throw new Error(`${toolId}: '${pluginName}' directory disappeared during update.`); + } + parent = dirname(parent); + } + } +} + async function tryRealpath(fs: FileReader, path: string): Promise { try { return await fs.realpath(path); diff --git a/cli/src/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.ts deleted file mode 100644 index 695221f92..000000000 --- a/cli/src/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { join } from "node:path"; -import { type MergeFileEntry, removeEntriesFromJson } from "../../../../kernel/merge.js"; -import type { FileReader } from "../../../../kernel/ports/file-reader.js"; -import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; -import type { Logger } from "../../../../kernel/ports/logger.js"; -import type { ToolId } from "../../../../kernel/tool.js"; -import type { McpExclusion } from "../../../tools/domain/mcp-exclusion.js"; -import type { Manifest } from "../../domain/manifest.js"; - -export interface UninstallMcpExclusionOptions { - toolId: ToolId; - manifest: Manifest; - projectRoot: string; - mcpFilter: string[]; -} - -export interface UninstallMcpExclusionResult { - toolId: ToolId; - fileCount: number; - deletedFiles: string[]; -} - -export class UninstallMcpExclusionUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly logger: Logger - ) {} - - async execute(options: UninstallMcpExclusionOptions): Promise { - const { toolId, manifest, projectRoot, mcpFilter } = options; - this.logger.info(`Removing MCP entries from ${toolId}...`); - const mergeFiles = manifest.getMergeFiles(toolId); - const removedKeys: string[] = []; - const exclusions: McpExclusion[] = []; - for (const mergeFile of mergeFiles) { - const r = await this.processOneMergeFile(mergeFile, projectRoot, mcpFilter); - removedKeys.push(...r.keys); - exclusions.push(...r.exclusions); - } - this.rebuildMergeEntries(toolId, manifest, mcpFilter); - manifest.addExcludedMcp(toolId, exclusions); - return { toolId, fileCount: removedKeys.length, deletedFiles: removedKeys }; - } - - private async processOneMergeFile( - mergeFile: MergeFileEntry, - projectRoot: string, - mcpFilter: string[] - ): Promise<{ keys: string[]; exclusions: McpExclusion[] }> { - if (mergeFile.sectionKey === null) return { keys: [], exclusions: [] }; - const matching = mcpFilter.filter((k) => mergeFile.entries[k] !== undefined); - if (matching.length === 0) return { keys: [], exclusions: [] }; - await this.removeKeysFromJsonFile( - join(projectRoot, mergeFile.relativePath), - mergeFile.sectionKey, - matching - ); - const exclusions = matching.map((k) => ({ configPath: mergeFile.relativePath, entryKey: k })); - return { keys: matching, exclusions }; - } - - private async removeKeysFromJsonFile( - fullPath: string, - sectionKey: string | null, - keysToRemove: string[] - ): Promise { - const content = await this.fs.readFile(fullPath); - await this.fs.writeFile(fullPath, removeEntriesFromJson(content, sectionKey, keysToRemove)); - } - - private rebuildMergeEntries(toolId: ToolId, manifest: Manifest, removedKeys: string[]): void { - const mergeFiles = manifest.getMergeFiles(toolId); - const removedSet = new Set(removedKeys); - const updated = mergeFiles.map((mf) => { - const entries = { ...mf.entries }; - for (const key of removedSet) delete entries[key]; - return { ...mf, entries }; - }); - manifest.updateToolMergeFiles(toolId, updated); - } -} diff --git a/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts index 49dbb10a3..e47d67504 100644 --- a/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts +++ b/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts @@ -1,9 +1,13 @@ import { NoManifestError, PluginNotFoundError } from "../../../../kernel/errors.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; import { AI_TOOL_IDS } from "../../../../kernel/tool.js"; import type { Manifest } from "../../domain/manifest.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { detachNativePluginRefs } from "../ownership/native-plugin-ownership.js"; +import { ProjectPluginCleanup } from "../ownership/project-plugin-cleanup.js"; +import { detachUserPlugin } from "../ownership/user-plugin-ownership.js"; import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; export interface UninstallPluginOptions { @@ -20,8 +24,9 @@ export interface UninstallPluginResult { export class UninstallPluginUseCase { constructor( - private readonly fs: FileWriter, - private readonly manifestRepo: ManifestRepository + private readonly fs: FileWriter & FileReader, + private readonly manifestRepo: ManifestRepository, + private readonly userManifestRepo?: ManifestRepository ) {} async execute(options: UninstallPluginOptions): Promise { @@ -29,9 +34,27 @@ export class UninstallPluginUseCase { const manifest = await this.manifestRepo.load(); if (manifest === null) throw new NoManifestError(); const scope = this.resolveToolScope(toolIds, manifest); + const userTools = scope.filter((toolId) => + manifest.getPlugins(toolId).some((p) => p.name === pluginName && p.scope === "user") + ); + const nativeRefs = new Map(); + const cleanup = new ProjectPluginCleanup(this.fs, this.userManifestRepo); + for (const toolId of scope) { + const plugin = manifest.getPlugins(toolId).find((candidate) => candidate.name === pluginName); + if (plugin !== undefined) + await cleanup.assertLocalIntegrationRemovable(toolId, plugin, projectRoot); + const hostName = manifest + .getNativeRegistrations(toolId) + ?.marketplaces.find((m) => m.alias === plugin?.marketplace)?.hostName; + if (hostName !== undefined) nativeRefs.set(toolId, [`${pluginName}@${hostName}`]); + } const results = await this.removeFromTools(pluginName, scope, projectRoot, manifest); if (results.length === 0) throw new PluginNotFoundError(pluginName); await this.manifestRepo.save(manifest); + await detachNativePluginRefs(this.userManifestRepo, this.fs, projectRoot, nativeRefs); + for (const toolId of userTools) { + await detachUserPlugin(this.userManifestRepo, this.fs, toolId, pluginName, projectRoot); + } return results; } @@ -47,9 +70,22 @@ export class UninstallPluginUseCase { manifest: Manifest ): Promise { const results: UninstallPluginResult[] = []; + const cleanup = new ProjectPluginCleanup(this.fs, this.userManifestRepo); for (const toolId of toolIds) { const plugin = manifest.getPlugins(toolId).find((p) => p.name === pluginName); if (plugin === undefined) continue; + await cleanup.removeLocalIntegration(toolId, plugin, projectRoot); + const registrations = manifest.getNativeRegistrations(toolId); + const hostName = registrations?.marketplaces.find( + (m) => m.alias === plugin.marketplace + )?.hostName; + if (registrations !== undefined && hostName !== undefined) + manifest.setNativeRegistrations(toolId, { + ...registrations, + pluginRefs: registrations.pluginRefs.filter( + (ref) => ref !== `${plugin.name}@${hostName}` + ), + }); const deletedFiles = await deletePluginFilesForTool( plugin.files, plugin.scope, diff --git a/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts index 006b1bdb2..9b2e7e8ac 100644 --- a/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts +++ b/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts @@ -7,10 +7,13 @@ import { import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Logger } from "../../../../kernel/ports/logger.js"; -import type { ToolId } from "../../../../kernel/tool.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; import { isAiToolId } from "../../../../kernel/tool.js"; import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; import type { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { detachNativePluginRefs } from "../ownership/native-plugin-ownership.js"; +import { detachUserPlugin } from "../ownership/user-plugin-ownership.js"; import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; export interface UninstallToolsOptions { @@ -28,7 +31,8 @@ export interface UninstallToolsResult { export class UninstallToolsUseCase { constructor( private readonly fs: FileReader & FileWriter, - private readonly logger: Logger + private readonly logger: Logger, + private readonly userManifestRepo?: ManifestRepository ) {} async execute(options: UninstallToolsOptions): Promise { @@ -40,6 +44,17 @@ export class UninstallToolsUseCase { return results; } + async detachClaimsAfterSave( + projectRoot: string, + userPlugins: readonly { toolId: AiToolId; name: string }[], + nativeRefs: ReadonlyMap + ): Promise { + await detachNativePluginRefs(this.userManifestRepo, this.fs, projectRoot, nativeRefs); + for (const { toolId, name } of userPlugins) { + await detachUserPlugin(this.userManifestRepo, this.fs, toolId, name, projectRoot); + } + } + private async removeOneTool( toolId: ToolId, allToolIds: ToolId[], diff --git a/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts index 0354b0b2f..56def6420 100644 --- a/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts +++ b/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts @@ -7,17 +7,15 @@ import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Logger } from "../../../../kernel/ports/logger.js"; import type { ToolId } from "../../../../kernel/tool.js"; -import { VALID_TOOL_IDS } from "../../../../kernel/tool.js"; +import { isAiToolId, VALID_TOOL_IDS } from "../../../../kernel/tool.js"; import type { Manifest } from "../../domain/manifest.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; -import { UninstallMcpExclusionUseCase } from "./uninstall-mcp-exclusion-use-case.js"; import { UninstallPluginUseCase } from "./uninstall-plugin-use-case.js"; import { UninstallToolsUseCase } from "./uninstall-tools-use-case.js"; interface UninstallOptions { toolIds: ToolId[]; projectRoot: string; - mcpFilter: string[]; pluginName?: string; } @@ -30,20 +28,19 @@ interface UninstallToolResult { export class UninstallUseCase { private readonly pluginUninstall: UninstallPluginUseCase; private readonly toolsUninstall: UninstallToolsUseCase; - private readonly mcpExclusion: UninstallMcpExclusionUseCase; constructor( - fs: FileReader & FileWriter, + private readonly fs: FileReader & FileWriter, private readonly manifestRepo: ManifestRepository, - logger: Logger + logger: Logger, + userManifestRepo?: ManifestRepository ) { - this.pluginUninstall = new UninstallPluginUseCase(fs, manifestRepo); - this.toolsUninstall = new UninstallToolsUseCase(fs, logger); - this.mcpExclusion = new UninstallMcpExclusionUseCase(fs, logger); + this.pluginUninstall = new UninstallPluginUseCase(fs, manifestRepo, userManifestRepo); + this.toolsUninstall = new UninstallToolsUseCase(fs, logger, userManifestRepo); } async execute(options: UninstallOptions): Promise { - const { toolIds, projectRoot, mcpFilter, pluginName } = options; + const { toolIds, projectRoot, pluginName } = options; if (pluginName !== undefined) { return this.pluginUninstall.execute({ pluginName, toolIds, projectRoot }); @@ -56,13 +53,24 @@ export class UninstallUseCase { } const manifest = await this.loadAndValidate(toolIds); + const userPlugins = toolIds.flatMap((toolId) => + isAiToolId(toolId) + ? manifest + .getPlugins(toolId) + .filter((plugin) => plugin.scope === "user") + .map((plugin) => ({ toolId, name: plugin.name })) + : [] + ); + const nativeRefs = new Map(); + for (const toolId of toolIds) { + const refs = manifest.getNativeRegistrations(toolId)?.pluginRefs; + if (refs !== undefined && refs.length > 0) nativeRefs.set(toolId, refs); + } - const results = - mcpFilter.length > 0 - ? await this.runMcpExclusions(toolIds, manifest, projectRoot, mcpFilter) - : await this.toolsUninstall.execute({ toolIds, manifest, projectRoot }); + const results = await this.toolsUninstall.execute({ toolIds, manifest, projectRoot }); await this.manifestRepo.save(manifest); + await this.toolsUninstall.detachClaimsAfterSave(projectRoot, userPlugins, nativeRefs); return results; } @@ -74,17 +82,4 @@ export class UninstallUseCase { } return manifest; } - - private async runMcpExclusions( - toolIds: ToolId[], - manifest: Manifest, - projectRoot: string, - mcpFilter: string[] - ): Promise { - const results: UninstallToolResult[] = []; - for (const toolId of toolIds) { - results.push(await this.mcpExclusion.execute({ toolId, manifest, projectRoot, mcpFilter })); - } - return results; - } } diff --git a/cli/src/contexts/framework/domain/manifest.ts b/cli/src/contexts/framework/domain/manifest.ts index 280487cd1..dc5cd67c4 100644 --- a/cli/src/contexts/framework/domain/manifest.ts +++ b/cli/src/contexts/framework/domain/manifest.ts @@ -3,8 +3,6 @@ import type { FileHash, InstallationFile } from "../../../kernel/file.js"; import type { MergeFileEntry } from "../../../kernel/merge.js"; import { AIDD_DIR, MANIFEST_FILENAME } from "../../../kernel/paths.js"; import type { ToolId } from "../../../kernel/tool.js"; -import type { McpExclusion } from "../../tools/domain/mcp-exclusion.js"; -import { addExclusions, removeExclusions } from "./manifest/mcp-exclusions.js"; import type { NativeRegistrations } from "./manifest/native-registrations.js"; import { addPluginToEntry, @@ -59,8 +57,7 @@ export class Manifest { toolId: ToolId, version: string, files: InstallationFile[], - mergeFiles: MergeFileEntry[] = [], - excludedMcp: McpExclusion[] = [] + mergeFiles: MergeFileEntry[] = [] ): void { const existing = this._tools.get(toolId); this._tools.set( @@ -70,7 +67,6 @@ export class Manifest { version, files, mergeFiles, - excludedMcp, existingPlugins: existing?.plugins ?? [], }) ); @@ -109,34 +105,6 @@ export class Manifest { return tracked; } - getExcludedMcp(toolId: ToolId): readonly McpExclusion[] { - return this._tools.get(toolId)?.excludedMcp ?? []; - } - - addExcludedMcp(toolId: ToolId, exclusions: McpExclusion[]): void { - const entry = this._tools.get(toolId); - if (!entry) throw new ToolNotInManifestError(toolId); - this._tools.set(toolId, { - ...entry, - excludedMcp: addExclusions(entry.excludedMcp, exclusions), - }); - } - - removeExcludedMcp(toolId: ToolId, exclusions: McpExclusion[]): void { - const entry = this._tools.get(toolId); - if (!entry) throw new ToolNotInManifestError(toolId); - this._tools.set(toolId, { - ...entry, - excludedMcp: removeExclusions(entry.excludedMcp, exclusions), - }); - } - - clearExcludedMcp(toolId: ToolId): void { - const entry = this._tools.get(toolId); - if (!entry) throw new ToolNotInManifestError(toolId); - this._tools.set(toolId, { ...entry, excludedMcp: [] }); - } - updateTrackedFileHash(toolId: ToolId, relativePath: string, hash: FileHash): void { const entry = this._tools.get(toolId); if (!entry) return; @@ -146,18 +114,10 @@ export class Manifest { }); } - updateToolMergeFiles( - toolId: ToolId, - mergeFiles: MergeFileEntry[], - excludedMcp?: McpExclusion[] - ): void { + updateToolMergeFiles(toolId: ToolId, mergeFiles: MergeFileEntry[]): void { const entry = this._tools.get(toolId); if (!entry) throw new ToolNotInManifestError(toolId); - this._tools.set(toolId, { - ...entry, - mergeFiles, - ...(excludedMcp !== undefined && { excludedMcp }), - }); + this._tools.set(toolId, { ...entry, mergeFiles }); } removeTool(toolId: ToolId): void { diff --git a/cli/src/contexts/framework/domain/manifest/mcp-exclusions.ts b/cli/src/contexts/framework/domain/manifest/mcp-exclusions.ts deleted file mode 100644 index 877d2d156..000000000 --- a/cli/src/contexts/framework/domain/manifest/mcp-exclusions.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type McpExclusion, mcpExclusionEquals } from "../../../tools/domain/mcp-exclusion.js"; - -export interface McpExclusionData { - configPath: string; - entryKey: string; -} - -export function addExclusions( - existing: readonly McpExclusion[], - toAdd: readonly McpExclusion[] -): McpExclusion[] { - const result = [...existing]; - for (const excl of toAdd) { - if (!result.some((e) => mcpExclusionEquals(e, excl))) { - result.push(excl); - } - } - return result; -} - -export function removeExclusions( - existing: readonly McpExclusion[], - toRemove: readonly McpExclusion[] -): McpExclusion[] { - return existing.filter((e) => !toRemove.some((r) => mcpExclusionEquals(e, r))); -} - -export function toMcpExclusionData(exclusions: readonly McpExclusion[]): McpExclusionData[] { - return exclusions.map((e) => ({ configPath: e.configPath, entryKey: e.entryKey })); -} - -export function parseMcpExclusionData(data: readonly McpExclusionData[]): McpExclusion[] { - return data.map((e) => ({ configPath: e.configPath, entryKey: e.entryKey })); -} diff --git a/cli/src/contexts/framework/domain/manifest/native-registrations.ts b/cli/src/contexts/framework/domain/manifest/native-registrations.ts index 0a95699fe..fa6f4e754 100644 --- a/cli/src/contexts/framework/domain/manifest/native-registrations.ts +++ b/cli/src/contexts/framework/domain/manifest/native-registrations.ts @@ -1,3 +1,5 @@ +import type { NativeMarketplaceSource } from "../../../tools/domain/ports/native-marketplace-source-reader.js"; + /** One marketplace registration a tool's own CLI was asked to make — aidd's own local name for it * (`alias`, what this project's registry is keyed by) beside what the host actually registered it * under (`hostName`, the catalog's own declared name, which every host-facing call must use @@ -6,18 +8,27 @@ export interface NativeMarketplaceRegistration { readonly alias: string; readonly hostName: string; + /** Missing on legacy records: never infer current host ownership from the name alone. */ + readonly provenance?: NativeMarketplaceSource; } export interface NativeRegistrations { readonly binary: string; readonly marketplaces: readonly NativeMarketplaceRegistration[]; readonly pluginRefs: readonly string[]; + readonly pluginClaims?: readonly NativePluginClaim[]; +} + +export interface NativePluginClaim { + readonly ref: string; + readonly dependents: readonly string[]; } export interface NativeRegistrationsData { binary: string; marketplaces: NativeMarketplaceRegistration[]; pluginRefs: string[]; + pluginClaims?: NativePluginClaim[]; } export function toNativeRegistrationsData( @@ -25,8 +36,19 @@ export function toNativeRegistrationsData( ): NativeRegistrationsData { return { binary: registrations.binary, - marketplaces: registrations.marketplaces.map((m) => ({ ...m })), + marketplaces: registrations.marketplaces.map((m) => ({ + ...m, + ...(m.provenance === undefined ? {} : { provenance: { ...m.provenance } }), + })), pluginRefs: [...registrations.pluginRefs], + ...(registrations.pluginClaims === undefined + ? {} + : { + pluginClaims: registrations.pluginClaims.map((claim) => ({ + ref: claim.ref, + dependents: [...claim.dependents], + })), + }), }; } @@ -36,7 +58,33 @@ export function parseNativeRegistrations( if (data === undefined) return undefined; return { binary: data.binary, - marketplaces: data.marketplaces.map((m) => ({ alias: m.alias, hostName: m.hostName })), + marketplaces: data.marketplaces.map((m) => ({ + alias: m.alias, + hostName: m.hostName, + ...(validProvenance(m.provenance) ? { provenance: { ...m.provenance } } : {}), + })), pluginRefs: [...data.pluginRefs], + ...(data.pluginClaims === undefined + ? {} + : { + pluginClaims: data.pluginClaims.map((claim) => ({ + ref: claim.ref, + dependents: [...claim.dependents], + })), + }), }; } + +function validProvenance(value: unknown): value is NativeMarketplaceSource { + if (value === null || typeof value !== "object") return false; + const data = value as Record; + if (typeof data.source !== "string" || data.source === "") return false; + if (data.kind === "registry") return true; + return ( + data.kind === "effective-list" && + typeof data.root === "string" && + data.root !== "" && + typeof data.sourceType === "string" && + data.sourceType !== "" + ); +} diff --git a/cli/src/contexts/framework/domain/manifest/tool-entry.ts b/cli/src/contexts/framework/domain/manifest/tool-entry.ts index 5e84b596e..57adee527 100644 --- a/cli/src/contexts/framework/domain/manifest/tool-entry.ts +++ b/cli/src/contexts/framework/domain/manifest/tool-entry.ts @@ -2,13 +2,7 @@ import { DuplicatePluginError, PluginNotFoundError } from "../../../../kernel/er import type { InstallationFile } from "../../../../kernel/file.js"; import type { MergeFileEntry } from "../../../../kernel/merge.js"; import type { ToolId } from "../../../../kernel/tool.js"; -import type { McpExclusion } from "../../../tools/domain/mcp-exclusion.js"; import { InstalledPlugin, type PluginEntryData } from "../plugins/installed-plugin.js"; -import { - type McpExclusionData, - parseMcpExclusionData, - toMcpExclusionData, -} from "./mcp-exclusions.js"; import { type MergeFileEntryData, parseMergeFileEntries, @@ -33,7 +27,6 @@ export interface ToolEntry { readonly version: string; readonly files: readonly TrackedFile[]; readonly mergeFiles: readonly MergeFileEntry[]; - readonly excludedMcp: readonly McpExclusion[]; readonly plugins: readonly InstalledPlugin[]; /** What this tool's own CLI was asked to register, or `undefined` for a tool with * no `nativeActivation` — see {@link NativeRegistrations}. */ @@ -45,7 +38,6 @@ export interface ToolEntryData { version: string; files: TrackedFileData[]; mergeFiles?: MergeFileEntryData[]; - excludedMcp?: McpExclusionData[]; plugins?: PluginEntryData[]; nativeRegistrations?: NativeRegistrationsData; } @@ -55,7 +47,6 @@ export function createToolEntry(params: { version: string; files: InstallationFile[]; mergeFiles: readonly MergeFileEntry[]; - excludedMcp: readonly McpExclusion[]; existingPlugins: readonly InstalledPlugin[]; }): ToolEntry { return { @@ -63,7 +54,6 @@ export function createToolEntry(params: { version: params.version, files: toTrackedFiles(params.files), mergeFiles: params.mergeFiles, - excludedMcp: params.excludedMcp, plugins: params.existingPlugins, }; } @@ -104,7 +94,6 @@ export function serializeToolEntry(entry: ToolEntry): ToolEntryData { version: entry.version, files: toTrackedFileData(entry.files), mergeFiles: toMergeFileEntryData(entry.mergeFiles), - ...(entry.excludedMcp.length > 0 && { excludedMcp: toMcpExclusionData(entry.excludedMcp) }), ...(entry.plugins.length > 0 && { plugins: entry.plugins.map((p) => p.toJSON()) }), ...(entry.nativeRegistrations !== undefined && { nativeRegistrations: toNativeRegistrationsData(entry.nativeRegistrations), @@ -118,7 +107,6 @@ export function parseToolEntry(toolId: ToolId, data: ToolEntryData): ToolEntry { version: data.version, files: parseTrackedFiles(data.files), mergeFiles: parseMergeFileEntries(data.mergeFiles ?? []), - excludedMcp: parseMcpExclusionData(data.excludedMcp ?? []), plugins: (data.plugins ?? []).map((p) => InstalledPlugin.fromJSON(p)), nativeRegistrations: parseNativeRegistrations(data.nativeRegistrations), }; diff --git a/cli/src/contexts/framework/domain/plugins/installed-plugin.ts b/cli/src/contexts/framework/domain/plugins/installed-plugin.ts index 2ee323dd1..1753f048c 100644 --- a/cli/src/contexts/framework/domain/plugins/installed-plugin.ts +++ b/cli/src/contexts/framework/domain/plugins/installed-plugin.ts @@ -41,6 +41,17 @@ export type ComponentPathMap = BrandedMap<"ComponentPathMap">; /** MCP server name → MD5 hash of the contributed server JSON (OpenCode merge tracking). */ export type McpDigestMap = BrandedMap<"McpDigestMap">; +/** Exact merged Cursor hook commands and copied project scripts recorded at install. */ +export interface ProjectHooksProvenance { + entries: readonly { event: string; command: string; digest: string }[]; + scripts: ReadonlyMap; +} + +interface ProjectHooksEntryData { + entries: { event: string; command: string; digest: string }[]; + scripts: Record; +} + function asPathHashMap(m: ReadonlyMap): PathHashMap { return m as PathHashMap; } @@ -64,7 +75,10 @@ export interface PluginEntryData { scope: PluginScope; componentPaths?: Record; mcpEntries?: Record; + projectHooks?: ProjectHooksEntryData; marketplace?: string; + /** Canonical project roots using machine-owned user-scope files. Only the user manifest owns this list. */ + dependents?: string[]; } export class InstalledPlugin { @@ -76,7 +90,9 @@ export class InstalledPlugin { readonly scope: PluginScope; readonly componentPaths: ComponentPathMap; readonly mcpEntries: McpDigestMap; + readonly projectHooks?: ProjectHooksProvenance; readonly marketplace?: string; + readonly dependents: readonly string[]; private constructor(params: { name: string; @@ -87,7 +103,9 @@ export class InstalledPlugin { scope: PluginScope; componentPaths: ComponentPathMap; mcpEntries: McpDigestMap; + projectHooks?: ProjectHooksProvenance; marketplace?: string; + dependents: readonly string[]; }) { this.name = params.name; this.source = params.source; @@ -97,7 +115,9 @@ export class InstalledPlugin { this.scope = params.scope; this.componentPaths = params.componentPaths; this.mcpEntries = params.mcpEntries; + this.projectHooks = params.projectHooks; this.marketplace = params.marketplace; + this.dependents = params.dependents; } static fromMetadata( @@ -133,7 +153,28 @@ export class InstalledPlugin { scope: plugin.scope, componentPaths: plugin.componentPaths, mcpEntries: asMcpDigestMap(mcpEntries), + projectHooks: plugin.projectHooks, + marketplace: plugin.marketplace, + dependents: plugin.dependents, + }); + } + + static withProjectHooks( + plugin: InstalledPlugin, + projectHooks: ProjectHooksProvenance | undefined + ): InstalledPlugin { + return new InstalledPlugin({ + name: plugin.name, + source: plugin.source, + version: plugin.version, + strict: plugin.strict, + files: plugin.files, + scope: plugin.scope, + componentPaths: plugin.componentPaths, + mcpEntries: plugin.mcpEntries, + projectHooks, marketplace: plugin.marketplace, + dependents: plugin.dependents, }); } @@ -209,7 +250,15 @@ export class InstalledPlugin { scope: data.scope, componentPaths: asComponentPathMap(componentPaths), mcpEntries: asMcpDigestMap(mcpEntries), + projectHooks: + data.projectHooks === undefined + ? undefined + : { + entries: data.projectHooks.entries, + scripts: new Map(Object.entries(data.projectHooks.scripts)), + }, marketplace: data.marketplace, + dependents: data.dependents ?? [], }); } @@ -224,7 +273,14 @@ export class InstalledPlugin { }; if (this.componentPaths.size > 0) data.componentPaths = mapToRecord(this.componentPaths); if (this.mcpEntries.size > 0) data.mcpEntries = mapToRecord(this.mcpEntries); + if (this.projectHooks !== undefined) { + data.projectHooks = { + entries: [...this.projectHooks.entries], + scripts: mapToRecord(this.projectHooks.scripts), + }; + } if (this.marketplace !== undefined) data.marketplace = this.marketplace; + if (this.dependents.length > 0) data.dependents = [...this.dependents]; return data; } @@ -242,7 +298,9 @@ export class InstalledPlugin { scope: this.scope, componentPaths: this.componentPaths, mcpEntries: this.mcpEntries, + projectHooks: this.projectHooks, marketplace: this.marketplace, + dependents: this.dependents, }); } @@ -256,7 +314,25 @@ export class InstalledPlugin { scope: this.scope, componentPaths: this.componentPaths, mcpEntries: this.mcpEntries, + projectHooks: this.projectHooks, + marketplace: this.marketplace, + dependents: this.dependents, + }); + } + + withDependents(dependents: readonly string[]): InstalledPlugin { + return new InstalledPlugin({ + name: this.name, + source: this.source, + version: this.version, + strict: this.strict, + files: this.files, + scope: this.scope, + componentPaths: this.componentPaths, + mcpEntries: this.mcpEntries, + projectHooks: this.projectHooks, marketplace: this.marketplace, + dependents: [...new Set(dependents)], }); } } diff --git a/cli/src/contexts/framework/domain/ports/manifest-repository.ts b/cli/src/contexts/framework/domain/ports/manifest-repository.ts index fb92cf05b..894fb850b 100644 --- a/cli/src/contexts/framework/domain/ports/manifest-repository.ts +++ b/cli/src/contexts/framework/domain/ports/manifest-repository.ts @@ -7,4 +7,6 @@ export interface ManifestRepository { load(): Promise; save(manifest: Manifest): Promise; delete(): Promise; + /** Optional for project repositories; user-scope mutations hold an inter-process lock here. */ + withExclusiveAccess?(action: () => Promise): Promise; } diff --git a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts index b1240fd9e..8aed72d64 100644 --- a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts +++ b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts @@ -1,8 +1,10 @@ -import { rm } from "node:fs/promises"; +import { mkdir, rm, rmdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { setTimeout as pause } from "node:timers/promises"; import { userManifestPath } from "../../../kernel/paths.js"; import type { Manifest } from "../domain/manifest.js"; import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; -import { readManifestFile, writeManifestFile } from "./manifest-file-io.js"; +import { readManifestFile } from "./manifest-file-io.js"; /** * The user-scope counterpart of `ManifestRepositoryAdapter` — same schema, same version and refusal @@ -14,7 +16,10 @@ import { readManifestFile, writeManifestFile } from "./manifest-file-io.js"; * a live bug here. */ export class UserManifestRepositoryAdapter implements ManifestRepository { - constructor(private readonly userConfigDir: () => string) {} + constructor( + private readonly userConfigDir: () => string, + private readonly atomicWriter: (path: string, content: string) => Promise + ) {} get path(): string { return userManifestPath(this.userConfigDir()); @@ -29,10 +34,36 @@ export class UserManifestRepositoryAdapter implements ManifestRepository { } async save(manifest: Manifest): Promise { - await writeManifestFile(this.path, manifest); + await mkdir(dirname(this.path), { recursive: true }); + await this.atomicWriter(this.path, JSON.stringify(manifest.toJSON(), null, 2)); } async delete(): Promise { await rm(this.path, { force: true }); } + + async withExclusiveAccess(action: () => Promise): Promise { + const lock = `${this.path}.lock`; + await mkdir(dirname(lock), { recursive: true }); + const deadline = Date.now() + 30_000; + while (true) { + try { + await mkdir(lock); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (Date.now() >= deadline) { + throw new Error( + `User manifest is busy: ${lock}. Retry after the other AIDD operation finishes.` + ); + } + await pause(100); + } + } + try { + return await action(); + } finally { + await rmdir(lock); + } + } } diff --git a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts b/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts index 2a1758423..2e3e101be 100644 --- a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts +++ b/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts @@ -17,6 +17,11 @@ import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence import type { TelemetrySink } from "../domain/ports/telemetry-sink.js"; import type { VersionControl } from "../domain/ports/version-control.js"; import { resolveSessionAnchor } from "../domain/session-anchor.js"; +import { + anchorProjectElsewhere, + resolveSessionProject, + type SessionProject, +} from "../domain/session-project.js"; import { attributeMoment, buildStepIntervals, @@ -230,6 +235,7 @@ export class DiagnoseTelemetryUseCase { const unrecognisedPayload = await this.evidence.readUnrecognisedPayload(options.projectRoot); const hookTrust = await this.resolveHookTrust(options.env, currentSessionId); const toolReads = await this.gatherToolReads(journals); + const anchorInAnotherProject = await this.resolveAnchorProject(currentSessionId, journals); return { journals: journals.map(toClaimJournal), toolReads, @@ -237,6 +243,7 @@ export class DiagnoseTelemetryUseCase { currentSessionId, unrecognisedPayloadAt: unrecognisedPayload?.at, hookTrust, + ...(anchorInAnotherProject === null ? {} : { anchorInAnotherProject }), recorderDeclared: recorderDeclaration.declared, recorderDeclarationReadable: recorderDeclaration.unreadable.length === 0, foreignSchemaVersions: await this.runJournalReader.listForeignSchemas(), @@ -258,6 +265,21 @@ export class DiagnoseTelemetryUseCase { return null; } + /** Asked only of an anchor that left no run file here: one journalled here is ours already. */ + private async resolveAnchorProject( + currentSessionId: string | undefined, + journals: readonly RunJournal[] + ): Promise { + if (currentSessionId === undefined) return null; + if (journals.some((journal) => journal.session?.vendor_id === currentSessionId)) return null; + const here = journals + .map(resolveSessionProject) + .filter((project): project is SessionProject => project !== null); + if (here.length === 0) return null; + const stored = await this.telemetrySink.readRecordsForVendor(currentSessionId); + return anchorProjectElsewhere(here, stored); + } + // Only Codex gates a hook behind a trust grant it can decline in silence: a session // running under any other tool has nothing to read here, and asks nothing of it. private async resolveHookTrust( diff --git a/cli/src/contexts/telemetry/domain/session-project.ts b/cli/src/contexts/telemetry/domain/session-project.ts index 530399cc1..e34c33c53 100644 --- a/cli/src/contexts/telemetry/domain/session-project.ts +++ b/cli/src/contexts/telemetry/domain/session-project.ts @@ -9,6 +9,12 @@ export interface SessionProject { readonly projectField: ProjectField; } +/** A stored record's project, and which field named it — the pair, never the value alone. */ +export interface RecordProjectClaim { + readonly project_id?: string; + readonly project_field?: string; +} + /** `project_remote` wins when present: one value for every checkout of a repository, where * `project_id` carries no such guarantee. Neither field named answers `null`, never a guess. */ export function resolveSessionProject(journal: RunJournal | null): SessionProject | null { @@ -22,3 +28,31 @@ export function resolveSessionProject(journal: RunJournal | null): SessionProjec } return null; } + +function projectOfRecord(record: RecordProjectClaim): SessionProject | null { + const { project_id: projectId, project_field: projectField } = record; + if (projectId === undefined || projectId === "") return null; + if (projectField !== "project_id" && projectField !== "project_remote") return null; + return { projectId, projectField }; +} + +function sameProject(left: SessionProject, right: SessionProject): boolean { + return left.projectField === right.projectField && left.projectId === right.projectId; +} + +/** The project the stored records put an anchored session under, when it is not one of `here`'s. + * Compares only values named by the same field — `project_id` is a directory name and + * `project_remote` a URL, so across the two a match reads as a mismatch — and one record naming + * a project in `here` settles the session as this project's, whatever the others say. */ +export function anchorProjectElsewhere( + here: readonly SessionProject[], + anchorRecords: readonly RecordProjectClaim[] +): string | null { + const comparable = anchorRecords + .map(projectOfRecord) + .filter((project): project is SessionProject => project !== null) + .filter((project) => here.some((mine) => mine.projectField === project.projectField)); + if (comparable.length === 0) return null; + if (comparable.some((project) => here.some((mine) => sameProject(mine, project)))) return null; + return [...new Set(comparable.map((project) => project.projectId))].join(", "); +} diff --git a/cli/src/contexts/telemetry/domain/telemetry-claim.ts b/cli/src/contexts/telemetry/domain/telemetry-claim.ts index 9dec40064..e10e34ef6 100644 --- a/cli/src/contexts/telemetry/domain/telemetry-claim.ts +++ b/cli/src/contexts/telemetry/domain/telemetry-claim.ts @@ -32,6 +32,7 @@ export type TelemetryClaimReason = | NoRunFileReason | "unrecognised-payload" | "session-left-no-run-file" + | "anchor-in-another-project" | "no-session-anchor" | "turn-closed" | "only-session-start" @@ -88,6 +89,10 @@ export interface TelemetryEvidence { readonly currentSessionId?: string; readonly unrecognisedPayloadAt?: string; readonly hookTrust?: TelemetryCodexHookTrust; + /** The project the stored records put the anchored session under, present only when that is + * decidably not this one: an anchor is inherited by any process nested inside a session, + * whatever directory it runs in. */ + readonly anchorInAnotherProject?: string; /** Whether the recorder is declared anywhere this build checks — read the same way * `TelemetrySetup`'s own `recorderDeclaration` is, so the two cannot disagree. Never proof * the hook will fire: a declaration can be silently dropped. */ @@ -256,13 +261,30 @@ function noAnchorClaim(journals: readonly TelemetryClaimJournal[], latest: strin }; } +function anotherProjectClaim(project: string, latest: string): TelemetryClaim { + return { + claim: "hook-fired", + verdict: "unknown", + reason: "anchor-in-another-project", + detail: + `this session belongs to ${project}, not to this project — its stored figures name that ` + + `project, and nothing here is evidence about its hook. The newest run file here is from ` + + latest, + }; +} + function sessionAnchoredClaim( journals: readonly TelemetryClaimJournal[], latest: string, currentSessionId: string, - hookTrust: TelemetryCodexHookTrust | undefined + hookTrust: TelemetryCodexHookTrust | undefined, + anchorInAnotherProject: string | undefined ): TelemetryClaim { if (!firedForSession(journals, currentSessionId)) { + // Before the trust gate: local trust state says nothing about another project's session. + if (anchorInAnotherProject !== undefined) { + return anotherProjectClaim(anchorInAnotherProject, latest); + } if (hookTrust && trustExplainsAbsence(hookTrust)) return untrustedHookClaim(hookTrust); return { claim: "hook-fired", @@ -301,7 +323,8 @@ function claimHookFired(evidence: TelemetryEvidence): TelemetryClaim { sessionJournals, latest, evidence.currentSessionId, - evidence.hookTrust + evidence.hookTrust, + evidence.anchorInAnotherProject ); } diff --git a/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts b/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts index 0a7ce0384..635eea2b0 100644 --- a/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts +++ b/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts @@ -102,12 +102,8 @@ export function telemetrySinkRecordDayKey(record: TelemetrySinkRecord): string | // casts the rest, so a number here would parse as epoch milliseconds, land outside every // real period and go missing from the read without being counted as undated. if (typeof at !== "string") return undefined; - // The parse is checked first, always: the slice below is a faster way to read a moment - // already known to parse, never a substitute for checking it does. Slicing first lets a - // string merely shaped like a moment ("not-a-momentZ") answer a calendar fragment. const parsed = new Date(at); if (Number.isNaN(parsed.getTime())) return undefined; - if (at.length >= DAY_KEY_LENGTH && at.endsWith("Z")) return at.slice(0, DAY_KEY_LENGTH); return parsed.toISOString().slice(0, DAY_KEY_LENGTH); } diff --git a/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts index ac65d1386..9d7c55511 100644 --- a/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts @@ -95,13 +95,13 @@ function parseBoundary(parsed: RawJournalLine): RunJournalBoundary | null { return { type: "step_start", at, skill, ...(turnId === undefined ? {} : { turn_id: turnId }) }; } -/** A plain checkout writes neither key; `asString` rejects `""`, so a torn or empty value - * reads as "not stated" rather than as a worktree named nothing. */ +/** A plain checkout writes neither key, and an empty value is dropped with them, so a torn + * or empty value reads as "not stated" rather than as a worktree named nothing. */ function parseWorktree( parsed: RawJournalLine ): Pick { - const worktreeId = asString(parsed.worktree_id); - const worktreeRepoId = asString(parsed.worktree_repo_id); + const worktreeId = asString(parsed.worktree_id) || undefined; + const worktreeRepoId = asString(parsed.worktree_repo_id) || undefined; return { ...(worktreeId === undefined ? {} : { worktree_id: worktreeId }), ...(worktreeRepoId === undefined ? {} : { worktree_repo_id: worktreeRepoId }), diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index 250c1d7fb..dbc9c403b 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -33,6 +33,8 @@ export interface NativeActivation { /** Verb this CLI uses to re-index its marketplaces, after `plugin marketplace`. Omit when * plugins are not enabled through the CLI. */ upgradeVerb?: string; + /** Exact host command for updating one installed plugin ref. Copilot declares `update`; Codex does not. */ + updateVerb?: string; /** Verb this CLI uses to enable a plugin, after `plugin`. Omit when the tool loads plugins * from a project file this CLI writes. */ enableVerb?: string; diff --git a/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts b/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts index 55dba42ca..099c477f0 100644 --- a/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts +++ b/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { Hasher } from "../../../../kernel/ports/hasher.js"; import { stripJsonComments } from "../../../../kernel/reading/jsonc.js"; @@ -24,11 +25,16 @@ export function mergeOpencodeMcp( mergedContent: string; contributedEntries: ReadonlyMap; collisions: ReadonlyArray; + editedPreviousEntries: ReadonlyArray; } { const { full, mcp } = parseExisting(existingContent); const incoming = parseIncoming(incomingTransformed); - const cleaned = stripPreviousEntries(mcp, previousEntriesForThisPlugin); - return applyIncoming(full, cleaned, incoming, previousEntriesForThisPlugin, hasher); + const { cleaned, editedPreviousEntries } = stripPreviousEntries( + mcp, + previousEntriesForThisPlugin, + hasher + ); + return { ...applyIncoming(full, cleaned, incoming, hasher), editedPreviousEntries }; } /** @@ -43,20 +49,34 @@ export function mergeOpencodeMcp( export function buildOpencodeFlatConfig( baseConfig: string, existing: string | null, - incoming: Record + incoming: Record, + mergedKeys: readonly string[] = [] ): string { const base = JSON.parse(baseConfig) as Record; const { full, mcp } = parseExisting(existing); const userKeys = { ...full }; for (const key of Object.keys(base)) delete userKeys[key]; delete userKeys.mcp; - const mergedMcp = { ...mcp, ...incoming }; + const mergedMcp = { ...mcp }; + for (const [name, server] of Object.entries(incoming)) { + if (!(name in mergedMcp)) mergedMcp[name] = server; + } const result: Record = { ...base, ...userKeys }; + for (const key of mergedKeys) { + const existingValues = arrayEntries(full[key]); + const baseValues = arrayEntries(base[key]); + const generated = baseValues.filter((value) => !existingValues.includes(value)); + result[key] = [...existingValues, ...generated]; + } delete result.mcp; if (Object.keys(mergedMcp).length > 0) result.mcp = mergedMcp; return JSON.stringify(result, null, 2); } +function arrayEntries(value: unknown): readonly unknown[] { + return Array.isArray(value) ? value : []; +} + /** Removes servers previously contributed by a plugin from opencode.json's mcp section. A key * absent from `entries` is left untouched. */ export function unmergeOpencodeMcp( @@ -65,7 +85,9 @@ export function unmergeOpencodeMcp( ): string { const parsed = JSON.parse(stripJsonComments(existingContent)) as OpencodeMcpSection; const mcp = { ...(parsed.mcp ?? {}) }; - for (const name of entries.keys()) { + for (const [name, digest] of entries) { + if (!(name in mcp)) continue; + assertContributedServerUnchanged(name, mcp[name], digest); delete mcp[name]; } return JSON.stringify({ ...parsed, mcp }, null, 2); @@ -91,20 +113,23 @@ function parseIncoming(transformed: string): Record { function stripPreviousEntries( existing: Record, - previous: ReadonlyMap -): Record { + previous: ReadonlyMap, + hasher: Hasher +): { cleaned: Record; editedPreviousEntries: readonly string[] } { const result = { ...existing }; - for (const name of previous.keys()) { - delete result[name]; + const editedPreviousEntries: string[] = []; + for (const [name, digest] of previous) { + if (!(name in result)) continue; + if (hasher.hash(JSON.stringify(result[name])).value === digest) delete result[name]; + else editedPreviousEntries.push(name); } - return result; + return { cleaned: result, editedPreviousEntries }; } function applyIncoming( full: Record, cleanedMcp: Record, incoming: Record, - previous: ReadonlyMap, hasher: Hasher ): { mergedContent: string; @@ -115,7 +140,7 @@ function applyIncoming( const contributed = new Map(); const collisions: string[] = []; for (const [name, server] of Object.entries(incoming)) { - if (name in cleanedMcp && !previous.has(name)) { + if (name in cleanedMcp) { collisions.push(`${name}: ${MCP_COLLISION_REASON}`); continue; } @@ -125,3 +150,15 @@ function applyIncoming( const mergedContent = JSON.stringify({ ...full, mcp }, null, 2); return { mergedContent, contributedEntries: contributed, collisions }; } + +function assertContributedServerUnchanged(name: string, server: unknown, digest: string): void { + if (!/^[0-9a-f]{32}$/.test(digest)) { + throw new Error( + `OpenCode MCP server '${name}' has an unproven install digest; removal refused.` + ); + } + const current = createHash("md5").update(JSON.stringify(server), "utf-8").digest("hex"); + if (current !== digest) { + throw new Error(`OpenCode MCP server '${name}' was edited after install; removal refused.`); + } +} diff --git a/cli/src/contexts/tools/domain/host-plugin-registration.ts b/cli/src/contexts/tools/domain/host-plugin-registration.ts index 1b7667c63..fdf04ca6b 100644 --- a/cli/src/contexts/tools/domain/host-plugin-registration.ts +++ b/cli/src/contexts/tools/domain/host-plugin-registration.ts @@ -154,7 +154,7 @@ export function answeredRegistry( if (reading.refs === undefined) { return { answer: "unanswerable", - detail: `${reading.location} could not be read — ${reading.unreadable ?? "no reason given"}`, + detail: `${reading.location} could not be read — ${reading.absent ? "ENOENT" : (reading.unreadable ?? "no reason given")}`, }; } return { refs: reading.refs, location: reading.location }; diff --git a/cli/src/contexts/tools/domain/marketplace-settings.ts b/cli/src/contexts/tools/domain/marketplace-settings.ts index 6f6f83550..095ce2100 100644 --- a/cli/src/contexts/tools/domain/marketplace-settings.ts +++ b/cli/src/contexts/tools/domain/marketplace-settings.ts @@ -12,6 +12,9 @@ export interface MarketplaceSettings { settingsPath: string; settingsKey: string; enabledPluginsKey?: string; + /** A true entry installs the plugin when the host next loads this repository. Only project + * native refs proven during this run may be projected into this file. */ + declarativePluginActivationRequiresNativeProof?: boolean; /** * Where the tool keeps its registered marketplaces, for `doctor`, which checks the tool * actually wrote one. A path names a file of its own, which this CLI neither commits nor diff --git a/cli/src/contexts/tools/domain/mcp-exclusion.ts b/cli/src/contexts/tools/domain/mcp-launch-command.ts similarity index 78% rename from cli/src/contexts/tools/domain/mcp-exclusion.ts rename to cli/src/contexts/tools/domain/mcp-launch-command.ts index 8cbe77d68..2933810bc 100644 --- a/cli/src/contexts/tools/domain/mcp-exclusion.ts +++ b/cli/src/contexts/tools/domain/mcp-launch-command.ts @@ -28,12 +28,3 @@ function transformMcpForWin32(content: string): string { export function transformFor(platform: string): ((content: string) => string) | undefined { return platform === "win32" ? transformMcpForWin32 : undefined; } - -export interface McpExclusion { - readonly configPath: string; - readonly entryKey: string; -} - -export function mcpExclusionEquals(a: McpExclusion, b: McpExclusion): boolean { - return a.configPath === b.configPath && a.entryKey === b.entryKey; -} diff --git a/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts b/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts index 8f56d84b0..79f069c54 100644 --- a/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts +++ b/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts @@ -24,14 +24,13 @@ export interface HostPluginRegistryReading { /** The file consulted, named whatever it answered, so a person can open the same one. */ readonly location: string; /** - * Every ref the registry carries, mapped to what it says about it. **Absent, never empty, - * when the registry could not be read**: an empty map is a real answer, and keeping the two - * apart in the type is what stops a caller inventing "not registered" out of a permissions - * error. + * Every ref the registry carries. Absent when the file is missing or unreadable; only + * `absent: true` proves no registry exists. An empty map is a parsed file with no refs, + * distinct from a permissions error. */ readonly refs?: ReadonlyMap; - /** Why the registry could not be read: absent, unreadable, or holding something this reader - * will not pretend to understand. Present exactly when `refs` is absent. */ + readonly absent?: true; + /** Why an existing registry could not be read or understood. Never set with `absent`. */ readonly unreadable?: string; } diff --git a/cli/src/contexts/tools/domain/ports/native-marketplace-source-reader.ts b/cli/src/contexts/tools/domain/ports/native-marketplace-source-reader.ts new file mode 100644 index 000000000..edbc5f216 --- /dev/null +++ b/cli/src/contexts/tools/domain/ports/native-marketplace-source-reader.ts @@ -0,0 +1,29 @@ +/** Source currently selected by the host for a named native plugin marketplace. */ +export type NativeMarketplaceSource = + | { readonly kind: "registry"; readonly source: string } + | { + readonly kind: "effective-list"; + readonly root: string; + readonly sourceType: string; + readonly source: string; + }; + +export interface NativeMarketplaceSourceReading { + readonly location: string; + /** A missing name in a readable map proves absence; `null` means named but source unproven. */ + readonly entries?: ReadonlyMap; + /** Present only when no structured answer could be measured. */ + readonly unreadable?: string; +} + +export interface NativeMarketplaceSourceReader { + read(projectRoot: string): Promise; +} + +export interface NativeMarketplaceSourceListContract { + readonly binary: string; + readonly args: readonly string[]; + readonly parse?: (output: string) => ReadonlyMap; + readonly unavailableMessage: string; + readonly unverifiedMessage: string; +} diff --git a/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts b/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts index c2da415bb..eb01d94e7 100644 --- a/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts +++ b/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts @@ -22,8 +22,7 @@ export interface NativePluginActivator { * the tool offers no way to tell, which callers must read as "leave it alone": a registration * that might belong to a live project elsewhere is not one to take over. */ registrationState(name: string): "live" | "dead" | "unknown"; - /** Refreshes marketplace snapshots so plugin installs pick up new versions. No-op when unsupported. */ - upgradeMarketplaces(): void; + upgradeMarketplaces(name: string): void; /** * Installs and enables a plugin referenced as `@`. Idempotent. * @@ -34,6 +33,7 @@ export interface NativePluginActivator { * copilot) ignores it. */ enablePlugin(pluginRef: string, scope?: MarketplaceScope): void; + updatePlugin?(pluginRef: string): void; /** * Uninstalls a plugin referenced as `@`, the counterpart of * {@link enablePlugin}. May throw when the plugin is already absent from the tool's own diff --git a/cli/src/contexts/tools/domain/profiles/codex/native-marketplace-source.ts b/cli/src/contexts/tools/domain/profiles/codex/native-marketplace-source.ts new file mode 100644 index 000000000..6cec9ef77 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/codex/native-marketplace-source.ts @@ -0,0 +1,57 @@ +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceListContract, +} from "../../ports/native-marketplace-source-reader.js"; + +function object(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function parseCodexMarketplaceSources( + output: string +): ReadonlyMap { + const parsed = object(JSON.parse(output)); + if (!Array.isArray(parsed?.marketplaces)) throw new Error("unexpected Codex JSON shape"); + const entries = new Map(); + for (const item of parsed.marketplaces) { + const row = object(item); + if ( + typeof row?.name !== "string" || + row.name === "" || + typeof row.root !== "string" || + row.root === "" || + entries.has(row.name) + ) + throw new Error("ambiguous Codex marketplace JSON row"); + const source = row.marketplaceSource; + if (source === undefined) { + entries.set(row.name, null); + continue; + } + const sourceRow = object(source); + if ( + typeof sourceRow?.sourceType !== "string" || + sourceRow.sourceType === "" || + typeof sourceRow.source !== "string" || + sourceRow.source === "" + ) + throw new Error("unproven Codex marketplace source shape"); + entries.set(row.name, { + kind: "effective-list", + root: row.root, + sourceType: sourceRow.sourceType, + source: sourceRow.source, + }); + } + return entries; +} + +export const codexMarketplaceSourceListContract: NativeMarketplaceSourceListContract = { + binary: "codex", + args: ["plugin", "marketplace", "list", "--json"], + parse: parseCodexMarketplaceSources, + unavailableMessage: "Codex marketplace list --json unavailable; inspect the host CLI and retry", + unverifiedMessage: "Codex marketplace list --json output shape is not verified", +}; diff --git a/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts b/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts new file mode 100644 index 000000000..f3803b181 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts @@ -0,0 +1,11 @@ +import type { NativeMarketplaceSourceListContract } from "../../ports/native-marketplace-source-reader.js"; + +/** Installed Copilot 1.0.83 rejects --json; no structured source shape is verified yet. */ +export const copilotMarketplaceSourceListContract: NativeMarketplaceSourceListContract = { + binary: "copilot", + args: ["plugin", "marketplace", "list", "--json"], + unavailableMessage: + "Copilot CLI marketplace list --json unavailable; upgrade to a supported binary and retry", + unverifiedMessage: + "Copilot CLI marketplace list --json output shape is not verified; upgrade to a supported binary and reconcile manually", +}; diff --git a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts index 75830b677..b94791a8a 100644 --- a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts @@ -109,12 +109,6 @@ const commandsHandler = { const flat = flattenFileName(fileName, EXT_PROMPT); return `${DIRECTORY}prompts/${flat}`; }, - convertFrontmatter( - fm: Record, - relativeFileName: string - ): Record { - return convertCommandFrontmatter(fm, relativeFileName); - }, }; const rulesHandler = { @@ -299,9 +293,8 @@ export const copilot: AiTool< pluginRootToken: PLUGIN_ROOT_TOKEN, acceptsMcp: true, translationMode: "marketplace", - // Copilot treats enabledPlugins in settings.json as a recommendation, not an auto-install, - // and a project marketplace is not installable from project scope: `copilot plugin install` - // is what actually loads a plugin, while the settings file below surfaces recommendations. + // Copilot auto-installs repo-level enabledPlugins; a foreign catalogue at the same name + // must therefore never receive a declarative true entry before native source proof. // Its registry is global to the user and keyed by name, so a name held by a project that // no longer exists breaks every other project's installs — measured; `update` exits 1 on a // local path that is gone and 0 otherwise, which is what lets a dead name be reclaimed with @@ -309,6 +302,7 @@ export const copilot: AiTool< nativeActivation: { binary: "copilot", upgradeVerb: "update", + updateVerb: "update", enableVerb: "install", disableVerb: "uninstall", sourceCheckVerb: "update", @@ -317,18 +311,14 @@ export const copilot: AiTool< // binary. Never written by aidd; named here for a diagnostic alone. userSettingsPath: (h) => join(h, ".copilot", "settings.json"), }, - // VS Code Copilot reads this file, not the `copilot` CLI, which writes - // ~/.copilot/settings.json and leaves this one untouched. `chat.plugins.marketplaces` - // cannot stand in for it: it has application scope and VS Code rejects it in a workspace - // .vscode/settings.json. This file is a shared, committed recommendation, so - // `enabledPlugins`, which names plugins, belongs in it, while a marketplace registration - // naming an absolute path on one machine does not — hence `null`, with the registration - // driven through `copilot plugin install` instead. + // Copilot CLI and cloud agent read this repository file; enabledPlugins auto-installs. + // Keep machine-local marketplace paths out and project only proven native refs. marketplaceSettings: { settingsPath: ".github/copilot/settings.json", settingsKey: "extraKnownMarketplaces", marketplacesSettingsPath: null, enabledPluginsKey: "enabledPlugins", + declarativePluginActivationRequiresNativeProof: true, toEntryKey: claudeStyleMarketplaceKey, }, }), diff --git a/cli/src/contexts/tools/domain/profiles/kilo/build.ts b/cli/src/contexts/tools/domain/profiles/kilo/build.ts new file mode 100644 index 000000000..1703cb4f4 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/kilo/build.ts @@ -0,0 +1,135 @@ +/** Kilo's project distribution is flat. Its config and MCP format follow the OpenCode runtime, + * but Kilo has its own .kilo/ discovery paths and bundled base configuration. */ + +import { join } from "node:path"; +import { parseFrontmatter, serializeFrontmatter } from "../../../../../kernel/markdown.js"; +import { + flatHooksPathWithLoaderEntry, + flatMcpKeyPrefix, + genericFlatAgentPath, + genericFlatSkillTreePath, +} from "../../../../../kernel/materialization/flat-paths.js"; +import { rewriteRelativeLinks } from "../../../../../kernel/materialization/relative-link-rewrite.js"; +import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; +import type { ToolBuildContract } from "../../build-contract.js"; +import { buildOpencodeFlatConfig } from "../../formats/opencode-mcp-merge.js"; +import { transformMcpToOpencode } from "../opencode/build.js"; +import { generateKiloHooksBridge } from "./kilo-hooks-bridge.js"; +import { + KILO_DIRECTORY, + KILO_HOOKS_DIR, + KILO_PLUGIN_DIR, + KILO_PLUGIN_ENTRY_BASENAME, + makeKiloHooksBridgePath, + resolveKiloConfigPath, +} from "./kilo-paths.js"; + +type FsType = FileReader & FileWriter; + +function kiloFlatAgentPath(plugin: string, rel: string): string { + return genericFlatAgentPath( + `${KILO_DIRECTORY}agents/`, + plugin, + rel.replace(/^agents\//, ""), + ".md" + ); +} + +function kiloFlatSkillPath(plugin: string, rel: string): string { + return genericFlatSkillTreePath(`${KILO_DIRECTORY}skills/`, plugin, rel.replace(/^skills\//, "")); +} + +function kiloFlatResolveTarget(plugin: string, rel: string): string { + if (rel.startsWith("agents/")) return kiloFlatAgentPath(plugin, rel); + if (rel.startsWith("skills/")) return kiloFlatSkillPath(plugin, rel); + return rel; +} + +function kiloFlatHooksPath(plugin: string, rel: string): string { + return flatHooksPathWithLoaderEntry( + KILO_HOOKS_DIR, + { dir: KILO_PLUGIN_DIR, baseName: KILO_PLUGIN_ENTRY_BASENAME }, + plugin, + rel + ); +} + +function transformKiloFlatAgent(content: string, plugin: string, outName: string): string { + const { frontmatter, body } = parseFrontmatter(content); + const flatRelPath = kiloFlatAgentPath(plugin, `agents/${outName}`); + const rewrittenBody = rewriteRelativeLinks(body, { + currentFilePluginRelative: flatRelPath, + resolveTargetPath: (rel) => kiloFlatResolveTarget(plugin, rel), + }); + return serializeFrontmatter( + { ...frontmatter, name: `${plugin}-${outName.replace(/\.md$/, "")}`, mode: "subagent" }, + rewrittenBody + ); +} + +async function collectKiloMcp( + builtPlugins: readonly string[], + sourceDir: string, + fs: FsType +): Promise> { + const incoming: Record = {}; + for (const plugin of builtPlugins) { + const mcpSrc = `${sourceDir}/plugins/${plugin}/.mcp.json`; + if (!(await fs.fileExists(mcpSrc))) continue; + const transformed = JSON.parse(transformMcpToOpencode(await fs.readFile(mcpSrc))) as { + mcp?: Record; + }; + for (const [key, value] of Object.entries(transformed.mcp ?? {})) { + incoming[`${flatMcpKeyPrefix(plugin)}${key}`] = value; + } + } + return incoming; +} + +export function buildKiloFlatContract(): ToolBuildContract { + return { + manifestFileRelative: null, + synthesizeManifest: null, + manifestSchemaName: null, + artifacts: { + skills: { + supported: true, + source: { kind: "fullTree", srcDir: "skills" }, + path: kiloFlatSkillPath, + rewriteSkillName: true, + }, + agents: { + supported: true, + source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + path: kiloFlatAgentPath, + transform: transformKiloFlatAgent, + }, + mcp: { supported: false }, + hooks: { + supported: true, + source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + path: kiloFlatHooksPath, + skipHooksJson: true, + hooksBridge: { + generate: generateKiloHooksBridge, + path: makeKiloHooksBridgePath, + skipIfSourceHas: KILO_PLUGIN_ENTRY_BASENAME, + }, + }, + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: null, + buildMarketplaceEntry: null, + emitConfigArtifact: async (builtPlugins, outDir, sourceDir, fs, _validator, assetProvider) => { + const configPath = join(outDir, await resolveKiloConfigPath(outDir, fs)); + const existing = (await fs.fileExists(configPath)) ? await fs.readFile(configPath) : null; + const incoming = await collectKiloMcp(builtPlugins, sourceDir, fs); + const asset = assetProvider.loadConfigAsset("kilo", "kilo.json"); + const base = typeof asset === "string" ? asset : JSON.stringify(asset); + await fs.writeFile(configPath, buildOpencodeFlatConfig(base, existing, incoming)); + return 1; + }, + }; +} diff --git a/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts new file mode 100644 index 000000000..07c29a8df --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts @@ -0,0 +1,120 @@ +/** + * Kilo loads JavaScript modules from `.kilo/plugin/` and requires a default descriptor with + * `{ id, server }`. Declarative hooks.json has no Kilo runtime, so this generator turns its + * replayable SessionStart commands into that native module. Kilo documents `session.created` as + * the session lifecycle event; other Claude hook events deliberately remain unsupported until + * their Kilo payloads have been captured. + */ + +interface ClaudeHookItem { + readonly command?: string; +} + +interface ClaudeMatcherGroup { + readonly hooks?: readonly ClaudeHookItem[]; +} + +interface ClaudeHooksShape { + readonly hooks?: Record; +} + +interface ParsedHookCall { + readonly script: string; + readonly args: readonly string[]; +} + +const COMMAND_PATTERN = /^node\s+\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+)(.*)$/; + +function parseCommand(command: string | undefined): ParsedHookCall | null { + if (typeof command !== "string") return null; + const match = COMMAND_PATTERN.exec(command.trim()); + if (match === null) return null; + const [, script, rest] = match; + return { script, args: rest.trim().length === 0 ? [] : rest.trim().split(/\s+/) }; +} + +/** Parses only the one declarative event whose Kilo mapping is documented and intended here. */ +export function parseKiloSessionStartHooks(rawHooksJson: string): readonly ParsedHookCall[] { + const parsed = JSON.parse(rawHooksJson) as ClaudeHooksShape; + return (parsed.hooks?.SessionStart ?? []).flatMap((group) => + (group.hooks ?? []).flatMap((hook) => { + const parsedHook = parseCommand(hook.command); + return parsedHook === null ? [] : [parsedHook]; + }) + ); +} + +function toIdentifier(plugin: string): string { + return `${plugin + .split("-") + .filter(Boolean) + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join("")}KiloHooks`; +} + +/** Generates Kilo's default plugin descriptor, or no file where hooks.json has no replayable + * SessionStart command. The generated module keeps errors non-blocking so a failed memory + * refresh cannot prevent Kilo from creating a session. */ +export function generateKiloHooksBridge(rawHooksJson: string, plugin: string): string | null { + const sessionStart = parseKiloSessionStartHooks(rawHooksJson); + if (sessionStart.length === 0) return null; + const identifier = toIdentifier(plugin); + return `// Generated by aidd from plugins/${plugin}/hooks/hooks.json - do not edit by hand. +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const HOOKS_DIR = fileURLToPath(new URL("../hooks/${plugin}/", import.meta.url)); +const SESSION_START = ${JSON.stringify(sessionStart)}; + +function runHook(script, args, payload, directory) { + const child = spawn("node", [HOOKS_DIR + script, ...args], { + cwd: directory, + stdio: ["pipe", "ignore", "ignore"], + timeout: 5000, + }); + let reported = false; + const report = (error) => { + if (reported) return; + reported = true; + reportHookFailure(script, error); + }; + child.on("error", report); + child.on("close", (code, signal) => { + if (code !== 0 || signal !== null) { + report("exited with code " + String(code) + (signal === null ? "" : " (" + signal + ")")); + } + }); + child.stdin.on("error", report); + child.stdin.end(JSON.stringify(payload)); +} + +function reportHookFailure(script, error) { + console.warn("[aidd] Kilo session-start hook " + script + " failed: " + String(error)); +} + +function sessionStartCallsFor(event, directory) { + if (event?.type !== "session.created") return []; + return SESSION_START.map((hook) => ({ + script: hook.script, + args: hook.args, + payload: { hook_event_name: "SessionStart", session_id: null, cwd: directory }, + })); +} + +const ${identifier} = async ({ directory }) => ({ + event: async ({ event }) => { + try { + for (const call of sessionStartCallsFor(event, directory)) { + runHook(call.script, call.args, call.payload, directory); + } + } catch (error) { + console.warn("[aidd] Kilo session-start hook dispatch failed: " + String(error)); + } + }, +}); + +${identifier}.sessionStartCallsFor = sessionStartCallsFor; + +export default { id: "${plugin}-hooks", server: ${identifier} }; +`; +} diff --git a/cli/src/contexts/tools/domain/profiles/kilo/kilo-paths.ts b/cli/src/contexts/tools/domain/profiles/kilo/kilo-paths.ts new file mode 100644 index 000000000..e3ec31b80 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/kilo/kilo-paths.ts @@ -0,0 +1,37 @@ +/** Paths Kilo scans in a project. Kept apart from the profile so the build contract and the + * later hooks bridge share one declaration rather than duplicate Kilo's plugin layout. */ + +import { join } from "node:path"; +import { KiloDualConfigError } from "../../../../../kernel/errors.js"; +import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; + +export const KILO_DIRECTORY = ".kilo/"; +export const KILO_HOOKS_DIR = `${KILO_DIRECTORY}hooks/`; +export const KILO_PLUGIN_DIR = `${KILO_DIRECTORY}plugin/`; +export const KILO_PLUGIN_ENTRY_BASENAME = "kilo-plugin.js"; +export const KILO_PROJECT_CONFIG_JSON = `${KILO_DIRECTORY}kilo.json`; +export const KILO_PROJECT_CONFIG_JSONC = `${KILO_DIRECTORY}kilo.jsonc`; + +const KILO_LEGACY_CONFIG_JSON = "kilo.json"; +const KILO_LEGACY_CONFIG_JSONC = "kilo.jsonc"; + +export function makeKiloHooksBridgePath(plugin: string): string { + return `${KILO_PLUGIN_DIR}${plugin}-hooks.js`; +} + +/** Prefer the config beside Kilo's project artifacts. Existing root-level configuration stays + * user-owned and is reused rather than creating a second configuration variant. */ +export async function resolveKiloConfigPath(projectRoot: string, fs: FileReader): Promise { + const projectJson = await fs.fileExists(join(projectRoot, KILO_PROJECT_CONFIG_JSON)); + const projectJsonc = await fs.fileExists(join(projectRoot, KILO_PROJECT_CONFIG_JSONC)); + if (projectJson && projectJsonc) throw new KiloDualConfigError(); + if (projectJsonc) return KILO_PROJECT_CONFIG_JSONC; + if (projectJson) return KILO_PROJECT_CONFIG_JSON; + + const legacyJson = await fs.fileExists(join(projectRoot, KILO_LEGACY_CONFIG_JSON)); + const legacyJsonc = await fs.fileExists(join(projectRoot, KILO_LEGACY_CONFIG_JSONC)); + if (legacyJson && legacyJsonc) throw new KiloDualConfigError(); + if (legacyJsonc) return KILO_LEGACY_CONFIG_JSONC; + if (legacyJson) return KILO_LEGACY_CONFIG_JSON; + return KILO_PROJECT_CONFIG_JSONC; +} diff --git a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts new file mode 100644 index 000000000..4778bb776 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts @@ -0,0 +1,115 @@ +import { AgentsCapability } from "../../capabilities/agents-capability.js"; +import { CommandsCapability } from "../../capabilities/commands-capability.js"; +import { CONFIG_MCP } from "../../capabilities/config-refs.js"; +import { McpCapability } from "../../capabilities/mcp-capability.js"; +import { PluginsCapability } from "../../capabilities/plugins-capability.js"; +import { RulesCapability } from "../../capabilities/rules-capability.js"; +import { SkillsCapability } from "../../capabilities/skills-capability.js"; +import type { + AiTool, + HasAgents, + HasCommands, + HasMcp, + HasPlugins, + HasRules, + HasSkills, +} from "../../contracts.js"; +import { + buildAiddCommandFilePath, + convertCommandFrontmatterNoHint, + stripToolSuffix, +} from "../../formats/command.js"; +import { registerTool } from "../../registry.js"; +import { transformMcpToOpencode } from "../opencode/build.js"; +import { buildKiloFlatContract } from "./build.js"; +import { generateKiloHooksBridge } from "./kilo-hooks-bridge.js"; +import { + KILO_DIRECTORY, + KILO_HOOKS_DIR, + KILO_PLUGIN_DIR, + KILO_PLUGIN_ENTRY_BASENAME, + makeKiloHooksBridgePath, + resolveKiloConfigPath, +} from "./kilo-paths.js"; + +const TOOL_SUFFIX = ".kilo.md"; + +export const kilo: AiTool = { + kind: "ai", + toolId: "kilo", + distributionProbes: { marketplace: ["kilo.json"] }, + directory: KILO_DIRECTORY, + toolSuffix: TOOL_SUFFIX, + displayName: "Kilo Code", + telemetryLocalRead: { + kind: "unsupported", + reason: "Kilo OpenTelemetry is experimental and not yet supported by AIDD.", + }, + telemetryTaskAttributable: false, + signalDir: ".kilo/commands", + configOutputPaths: { "kilo.json": ".kilo/kilo.jsonc" }, + buildContracts: { flat: buildKiloFlatContract }, + + capabilities: { + agents: new AgentsCapability({ + directory: KILO_DIRECTORY, + toolSuffix: TOOL_SUFFIX, + format: "markdown", + convertFrontmatter: (fm) => ({ description: fm.description, mode: "subagent" }), + }), + skills: new SkillsCapability({ + directory: KILO_DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => + `${KILO_DIRECTORY}skills/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, + convertFrontmatter: (fm) => fm, + }), + commands: new CommandsCapability({ + directory: KILO_DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => buildAiddCommandFilePath(KILO_DIRECTORY, fileName), + convertFrontmatter: (fm, relativeFileName) => + convertCommandFrontmatterNoHint(fm, relativeFileName), + }), + rules: new RulesCapability({ + directory: KILO_DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => + `${KILO_DIRECTORY}rules/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, + convertFrontmatter: (fm) => + fm.alwaysApply === false && fm.description !== undefined + ? { description: fm.description } + : {}, + }), + mcp: new McpCapability({ + outputPath: "kilo.json", + format: "json", + entrySection: "mcp", + mergeStrategy: "framework-prime", + transformContent: transformMcpToOpencode, + consumes: [CONFIG_MCP], + resolveOutputPath: resolveKiloConfigPath, + }), + plugins: new PluginsCapability({ + mode: "flat", + flatNamespacePrefix: "aidd-", + acceptsHooks: true, + flatHooksDir: KILO_HOOKS_DIR, + flatHooksLoaderEntry: { dir: KILO_PLUGIN_DIR, baseName: KILO_PLUGIN_ENTRY_BASENAME }, + flatHooksBridge: { + generate: generateKiloHooksBridge, + path: makeKiloHooksBridgePath, + skipIfSourceHas: KILO_PLUGIN_ENTRY_BASENAME, + }, + }), + }, + + rewriteContent(content: string): string { + return content.replace( + /(@?)\.kilo\/commands\/(\d+)[_-][^/]+\/([^\s]+)/g, + "$1.kilo/commands/aidd/$2/$3" + ); + }, +}; + +registerTool(kilo); diff --git a/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts index 480b1b62e..109588375 100644 --- a/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts @@ -60,7 +60,7 @@ export abstract class AbstractNativePluginCliAdapter implements NativePluginActi abstract enablesPlugins(): boolean; abstract registrationState(name: string): "live" | "dead" | "unknown"; - abstract upgradeMarketplaces(): void; + abstract upgradeMarketplaces(name: string): void; abstract enablePlugin(pluginRef: string, scope?: MarketplaceScope): void; abstract uninstallPlugin(pluginRef: string, scope?: MarketplaceScope): void; diff --git a/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts b/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts index 085023c4e..60d84bd63 100644 --- a/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts @@ -10,6 +10,12 @@ import type { HostPluginRegistryReading, } from "../domain/ports/host-plugin-registry-reader.js"; +function failedRead(location: string, error: unknown): HostPluginRegistryReading { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return { location, absent: true }; + return { location, unreadable: describeError(error) }; +} + /** * One reader per host whose own plugin registry was measured; a tool absent from the map is * one nothing here claims to know, and the diagnostic reports it unanswerable rather than @@ -51,7 +57,7 @@ class ClaudeInstalledPluginsReader implements HostPluginRegistryReader { try { content = await readFile(this.path, "utf8"); } catch (error) { - return { location: this.path, unreadable: describeError(error) }; + return failedRead(this.path, error); } try { const parsed = JSON.parse(content) as { plugins?: Record }; @@ -90,7 +96,7 @@ class CodexConfigPluginsReader implements HostPluginRegistryReader { try { content = await readFile(this.path, "utf8"); } catch (error) { - return { location: this.path, unreadable: describeError(error) }; + return failedRead(this.path, error); } return { location: this.path, refs: scanCodexPluginTables(content) }; } @@ -109,7 +115,7 @@ class CopilotSettingsPluginsReader implements HostPluginRegistryReader { try { content = await readFile(this.path, "utf8"); } catch (error) { - return { location: this.path, unreadable: describeError(error) }; + return failedRead(this.path, error); } try { const parsed = JSON.parse(content) as { enabledPlugins?: Record }; diff --git a/cli/src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.ts b/cli/src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.ts new file mode 100644 index 000000000..2b3dc7187 --- /dev/null +++ b/cli/src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.ts @@ -0,0 +1,74 @@ +import { spawnSync } from "node:child_process"; +import type { HostMarketplaceRegistryReader } from "../domain/ports/host-marketplace-registry-reader.js"; +import type { + NativeMarketplaceSourceListContract, + NativeMarketplaceSourceReader, + NativeMarketplaceSourceReading, +} from "../domain/ports/native-marketplace-source-reader.js"; +import { + hostExecutableLookup, + resolveExecutableOnPath, + runsThroughShell, + windowsCommandLine, +} from "./executable-on-path.js"; + +export class HostRegistryMarketplaceSourceReaderAdapter implements NativeMarketplaceSourceReader { + constructor(private readonly hostReader: HostMarketplaceRegistryReader) {} + + async read(_projectRoot: string): Promise { + const reading = await this.hostReader.read(); + if (reading.entries !== undefined) + return { + location: reading.location, + entries: new Map( + [...reading.entries].map(([name, source]) => [ + name, + { kind: "registry" as const, source }, + ]) + ), + }; + if (reading.absent === true) return { location: reading.location, entries: new Map() }; + return { + location: reading.location, + unreadable: reading.unreadable ?? "Host marketplace registry is unreadable", + }; + } +} + +/** Uses only a host's structured, effective listing with the requesting project's CWD. */ +export class NativeMarketplaceSourceReaderAdapter implements NativeMarketplaceSourceReader { + constructor(private readonly contract: NativeMarketplaceSourceListContract) {} + + async read(projectRoot: string): Promise { + const location = `${this.contract.binary} ${this.contract.args.join(" ")} (cwd ${projectRoot})`; + const options = { + cwd: projectRoot, + encoding: "utf-8" as const, + timeout: 30000, + stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"], + shell: false, + }; + const executable = resolveExecutableOnPath(this.contract.binary, hostExecutableLookup()); + const result = + executable !== undefined && runsThroughShell(executable) + ? spawnSync(windowsCommandLine(executable, this.contract.args), { ...options, shell: true }) + : spawnSync(this.contract.binary, [...this.contract.args], options); + if (result.error !== undefined || result.status !== 0) { + const detail = result.error?.message ?? result.stderr?.trim() ?? "unknown failure"; + return { location, unreadable: `${this.contract.unavailableMessage}: ${detail}` }; + } + if (this.contract.parse === undefined) + return { + location, + unreadable: this.contract.unverifiedMessage, + }; + try { + return { location, entries: this.contract.parse(result.stdout ?? "") }; + } catch (error) { + return { + location, + unreadable: `${this.contract.binary} marketplace list could not prove a source: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } +} diff --git a/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts index 98cce54f3..dca4ee676 100644 --- a/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts @@ -7,6 +7,7 @@ export interface NativePluginCliShape { readonly forceRemoveArgs?: readonly string[]; readonly sourceCheckVerb?: string; readonly upgradeVerb?: string; + readonly updateVerb?: string; readonly enableVerb?: string; /** How the tool spells removing a plugin it installed: `remove` for codex, `uninstall` for * claude and copilot. Absent where this CLI enables plugins through a file it writes. */ @@ -43,10 +44,11 @@ export class NativePluginCliAdapter extends AbstractNativePluginCliAdapter { return this.succeeds(["plugin", "marketplace", verb, name]) ? "live" : "dead"; } - upgradeMarketplaces(): void { + upgradeMarketplaces(name: string): void { const verb = this.shape.upgradeVerb; if (verb === undefined) return; - this.run(["plugin", "marketplace", verb], `marketplace ${verb}`); + if (name.length === 0) throw new Error("A named marketplace is required for native refresh."); + this.run(["plugin", "marketplace", verb, name], `marketplace ${verb} ${name}`); } enablePlugin(pluginRef: string, scope: MarketplaceScope = "project"): void { @@ -58,6 +60,13 @@ export class NativePluginCliAdapter extends AbstractNativePluginCliAdapter { ); } + updatePlugin(pluginRef: string): void { + const verb = this.shape.updateVerb; + if (verb === undefined) + throw new Error(`${this.binary} does not support targeted native plugin update.`); + this.run(["plugin", verb, pluginRef], `plugin ${verb} ${pluginRef}`); + } + /** Undoes what `enablePlugin` did. `scope` must match what `enablePlugin` was called with: a * tool that installed at one scope and uninstalls at its default would silently miss the * entry it wrote. */ diff --git a/cli/src/kernel/errors.ts b/cli/src/kernel/errors.ts index d55531434..b3200cd8d 100644 --- a/cli/src/kernel/errors.ts +++ b/cli/src/kernel/errors.ts @@ -26,6 +26,13 @@ export class InvalidPluginScopeError extends Error { } } +export class ActiveMachineDependentsError extends Error { + constructor(target: string, projects: readonly string[]) { + super(`Cannot remove ${target}: active projects still depend on it: ${projects.join(", ")}.`); + this.name = "ActiveMachineDependentsError"; + } +} + export class AuthenticationError extends Error { constructor(source: string) { super(`Authentication failed (${source}). Run \`aidd auth login\` to authenticate.`); @@ -130,6 +137,13 @@ export class OpencodeDualConfigError extends Error { } } +export class KiloDualConfigError extends Error { + constructor() { + super("Both Kilo JSON and JSONC config variants exist at the same location. Remove one."); + this.name = "KiloDualConfigError"; + } +} + export class PackageManagerDetectionError extends Error { constructor(commands: readonly string[]) { super(`Could not detect package manager. Run manually:\n ${commands.join("\n ")}`); diff --git a/cli/src/kernel/paths.ts b/cli/src/kernel/paths.ts index 3311a59b4..4c1e3914b 100644 --- a/cli/src/kernel/paths.ts +++ b/cli/src/kernel/paths.ts @@ -194,6 +194,18 @@ export function pathContainsOrEquals(outer: string, inner: string): boolean { return normalizedOuter === normalizedInner || normalizedInner.startsWith(`${normalizedOuter}/`); } +/** Whether two paths name the same directory: separators normalised, and case folded the way + * a case-insensitive filesystem would — on win32 only, where a backslash separates and case + * does not count. Elsewhere a backslash is an ordinary character in a name. Both sides are + * expected already resolved — this + * compares spelling, it does not resolve. A `realpath` answer and a path a host stored name + * the same directory in two spellings on Windows, and `===` calls them different. */ +export function samePath(a: string, b: string, platform: string = process.platform): boolean { + if (platform !== "win32") return a === b; + const folded = (p: string): string => p.replace(/\\/g, "/").toLowerCase(); + return folded(a) === folded(b); +} + /** Either direction: neither may sit inside the other. */ export function pathsOverlap(a: string, b: string): boolean { return pathContainsOrEquals(a, b) || pathContainsOrEquals(b, a); diff --git a/cli/src/kernel/tool.ts b/cli/src/kernel/tool.ts index 291d92bba..6f8b968f5 100644 --- a/cli/src/kernel/tool.ts +++ b/cli/src/kernel/tool.ts @@ -1,6 +1,6 @@ import { UnknownAiToolIdError } from "./errors.js"; -export type AiToolId = "claude" | "cursor" | "copilot" | "opencode" | "codex"; +export type AiToolId = "claude" | "cursor" | "copilot" | "opencode" | "kilo" | "codex"; export type IdeToolId = "vscode"; export type ToolId = AiToolId | IdeToolId; export type ToolCategory = "ai" | "ide"; @@ -10,6 +10,7 @@ export const AI_TOOL_IDS: readonly AiToolId[] = [ "cursor", "copilot", "opencode", + "kilo", "codex", ]; export const IDE_TOOL_IDS: readonly IdeToolId[] = ["vscode"]; diff --git a/cli/src/presentation/commands/framework.ts b/cli/src/presentation/commands/framework.ts index 2ce15518a..f70be6269 100644 --- a/cli/src/presentation/commands/framework.ts +++ b/cli/src/presentation/commands/framework.ts @@ -102,7 +102,6 @@ async function runFrameworkRemove( const results = await deps.uninstallUseCase.execute({ toolIds: [toolId], projectRoot, - mcpFilter: [], }); const totalFileCount = results.reduce((sum, r) => sum + r.fileCount, 0); printToolRemoved(output, results[0].toolId, totalFileCount); diff --git a/cli/src/presentation/commands/marketplace.ts b/cli/src/presentation/commands/marketplace.ts index 7efa3baae..86294c1b2 100644 --- a/cli/src/presentation/commands/marketplace.ts +++ b/cli/src/presentation/commands/marketplace.ts @@ -1,4 +1,5 @@ import type { Command } from "commander"; +import { parseInstallScope } from "../../contexts/framework/domain/install-scope.js"; import type { MarketplaceScope } from "../../kernel/scope.js"; import { parsePluginSourceShorthand } from "../../kernel/source.js"; import { createDeps, createMenuDeps } from "../../runtime/wiring/framework.js"; @@ -111,17 +112,23 @@ export function registerMarketplaceCommand(program: Command): void { .command("remove ") .description("Remove a registered plugin marketplace") .option("--yes", "Skip the orphan-cleanup prompt") - .action(async (name: string, cmdOptions: { yes?: boolean }) => { + .option("--scope ", "Remove from project or user scope", "project") + .action(async (name: string, cmdOptions: { yes?: boolean; scope: string }) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const errorHandler = new ErrorHandler(output); try { + const scope = parseInstallScope(cmdOptions.scope) ?? "project"; const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.marketplaceRemoveUseCase.execute({ + const result = await (scope === "user" + ? deps.userMarketplaceRemoveUseCase + : deps.marketplaceRemoveUseCase + ).execute({ name, projectRoot, autoConfirm: cmdOptions.yes ?? false, + scope, }); - await syncNativeActivation(deps, output, projectRoot); + if (scope !== "user") await syncNativeActivation(deps, output, projectRoot); printMarketplaceRemoved(output, result.marketplace.name, result.removedPluginCount); } catch (error) { errorHandler.handle(error); diff --git a/cli/src/presentation/commands/plugin.ts b/cli/src/presentation/commands/plugin.ts index ce5256c26..15e69dd72 100644 --- a/cli/src/presentation/commands/plugin.ts +++ b/cli/src/presentation/commands/plugin.ts @@ -37,18 +37,21 @@ export function registerPluginCommand(program: Command): void { .command("remove ") .description("Remove a plugin from one or all AI tools") .option("--tool ", "Target AI tool (default: all installed)") - .action(async (name: string, cmdOptions: { tool?: string }) => { + .option("--scope ", "Removal scope (default: project)") + .action(async (name: string, cmdOptions: { tool?: string; scope?: string }) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const errorHandler = new ErrorHandler(output); try { assertValidAiToolId(cmdOptions.tool); + const scope = parseInstallScope(cmdOptions.scope) ?? "project"; const deps = await createDeps(projectRoot, { verbose }, output); await deps.pluginRemoveUseCase.execute({ pluginName: name, toolIds: parseToolOption(cmdOptions.tool), projectRoot, + scope, }); - await syncNativeActivation(deps, output, projectRoot); + if (scope === "project") await syncNativeActivation(deps, output, projectRoot); printPluginRemoved(output, name); } catch (error) { errorHandler.handle(error); @@ -147,18 +150,24 @@ export function registerPluginCommand(program: Command): void { .command("update [name]") .description("Update one or all plugins for one or all AI tools") .option("--tool ", "Target AI tool (default: all installed)") - .action(async (name: string | undefined, cmdOptions: { tool?: string }) => { + .option("--scope ", "Update scope (default: project)") + .action(async (name: string | undefined, cmdOptions: { tool?: string; scope?: string }) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const errorHandler = new ErrorHandler(output); try { assertValidAiToolId(cmdOptions.tool); + const scope = parseInstallScope(cmdOptions.scope) ?? "project"; const deps = await createDeps(projectRoot, { verbose }, output); - const updated = await deps.pluginUpdateUseCase.execute({ + const updated = await (scope === "user" + ? deps.userPluginUpdateUseCase + : deps.pluginUpdateUseCase + ).execute({ pluginNames: name !== undefined ? [name] : undefined, toolIds: parseToolOption(cmdOptions.tool), projectRoot, + scope, }); - await syncNativeActivation(deps, output, projectRoot); + if (scope === "project") await syncNativeActivation(deps, output, projectRoot); printPluginsUpdated(output, updated); } catch (error) { errorHandler.handle(error); diff --git a/cli/src/presentation/commands/translate.ts b/cli/src/presentation/commands/translate.ts index ba6dd806f..79830cce0 100644 --- a/cli/src/presentation/commands/translate.ts +++ b/cli/src/presentation/commands/translate.ts @@ -70,7 +70,10 @@ export function registerTranslateCommand(program: Command): void { "Convert an arbitrary source into a target-native plugin tree — records nothing (see `sync` for the manifest-driven, tracked version)" ) .argument("", "Path to the source framework directory") - .requiredOption("--to ", "Conversion target (claude, cursor, copilot, codex, opencode)") + .requiredOption( + "--to ", + "Conversion target (claude, cursor, copilot, codex, opencode, kilo)" + ) .requiredOption("--out ", "Output directory (marketplace dist or project root)") .option("--as ", "Output layout", "marketplace") .option("--force", "Overwrite existing files at canonical paths under --out") diff --git a/cli/src/presentation/prompts/menu-use-case.ts b/cli/src/presentation/prompts/menu-use-case.ts index 3e8833bb6..b58ae19d3 100644 --- a/cli/src/presentation/prompts/menu-use-case.ts +++ b/cli/src/presentation/prompts/menu-use-case.ts @@ -44,7 +44,7 @@ const INSTALLED_NODES: MenuNode[] = [ value: "doctor-tool", description: "Scope the report to a single AI or IDE tool", command: ["doctor", "--tool"], - inputPrompt: "Tool (e.g. claude, cursor, copilot, codex, opencode, vscode)", + inputPrompt: "Tool (e.g. claude, cursor, copilot, codex, opencode, kilo, vscode)", }, { name: "Plugins", @@ -64,7 +64,7 @@ const INSTALLED_NODES: MenuNode[] = [ value: "framework-install", description: "Add a tool to this project", command: ["framework", "install", "--tool"], - inputPrompt: "Tool (e.g. claude, cursor, copilot, codex, opencode, vscode)", + inputPrompt: "Tool (e.g. claude, cursor, copilot, codex, opencode, kilo, vscode)", }, { name: "Remove", diff --git a/cli/src/runtime/assets/asset-loader.ts b/cli/src/runtime/assets/asset-loader.ts index a6dba9ac1..2377e545a 100644 --- a/cli/src/runtime/assets/asset-loader.ts +++ b/cli/src/runtime/assets/asset-loader.ts @@ -6,6 +6,7 @@ import copilotVscodeSettings from "../../../assets/configs/copilot/vscode-settin type: "json", }; import cursorSettings from "../../../assets/configs/cursor/settings.json" with { type: "json" }; +import kiloJson from "../../../assets/configs/kilo/kilo.json" with { type: "json" }; import opencodeJson from "../../../assets/configs/opencode/opencode.json" with { type: "json" }; import vscodeExtensions from "../../../assets/configs/vscode/extensions.json" with { type: "json" }; import vscodeKeybindings from "../../../assets/configs/vscode/keybindings.json" with { @@ -26,6 +27,7 @@ const CONFIG_ASSETS: Readonly entry.trim() !== line); + const executable = await this.fs.isExecutable(hookPath); await this.fs.writeFile(hookPath, kept.join("\n")); + if (executable) await this.fs.chmodExecutable(hookPath); return true; } diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index 67c67b184..0f5d5eb2f 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -3,6 +3,7 @@ import "../../contexts/tools/domain/profiles/claude/profile.js"; import "../../contexts/tools/domain/profiles/codex/profile.js"; import "../../contexts/tools/domain/profiles/copilot/profile.js"; import "../../contexts/tools/domain/profiles/cursor/profile.js"; +import "../../contexts/tools/domain/profiles/kilo/profile.js"; import "../../contexts/tools/domain/profiles/opencode/profile.js"; import "../../contexts/tools/domain/profiles/vscode/profile.js"; import { MarketplaceAddUseCase } from "../../contexts/distribution/application/marketplace-add-use-case.js"; @@ -35,6 +36,12 @@ import { InstallIdeToolUseCase } from "../../contexts/framework/application/inst import { InstallRuntimeConfigUseCase } from "../../contexts/framework/application/install/install-runtime-config-use-case.js"; import { PostInstallPipelineUseCase } from "../../contexts/framework/application/install/post-install-pipeline-use-case.js"; import { ListInstalledRulesUseCase } from "../../contexts/framework/application/list-installed-rules-use-case.js"; +import { NativeHostRegistrationGate } from "../../contexts/framework/application/ownership/native-host-registration-gate.js"; +import { ProjectPluginCleanup } from "../../contexts/framework/application/ownership/project-plugin-cleanup.js"; +import { UserMarketplaceRemoveUseCase } from "../../contexts/framework/application/ownership/user-marketplace-remove-use-case.js"; +import { UserPluginDistributionLoader } from "../../contexts/framework/application/ownership/user-plugin-distribution-loader.js"; +import { UserPluginFileUpdater } from "../../contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { UserPluginUpdateUseCase } from "../../contexts/framework/application/ownership/user-plugin-update-use-case.js"; import { PluginAddUseCase } from "../../contexts/framework/application/plugin/plugin-add-use-case.js"; import { PluginInstallFromMarketplaceUseCase } from "../../contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; import { PluginInstallUseCase } from "../../contexts/framework/application/plugin/plugin-install-use-case.js"; @@ -66,13 +73,21 @@ import { PluginDistributionReaderAdapter } from "../../contexts/framework/infras import { UserManifestRepositoryAdapter } from "../../contexts/framework/infrastructure/user-manifest-repository-adapter.js"; import { UserSourceReferencesAdapter } from "../../contexts/framework/infrastructure/user-source-references-adapter.js"; import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; +import type { NativeMarketplaceSourceReader } from "../../contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { codexMarketplaceSourceListContract } from "../../contexts/tools/domain/profiles/codex/native-marketplace-source.js"; +import { copilotMarketplaceSourceListContract } from "../../contexts/tools/domain/profiles/copilot/native-marketplace-source.js"; import { hostPluginRegistryReaders } from "../../contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; +import { + HostRegistryMarketplaceSourceReaderAdapter, + NativeMarketplaceSourceReaderAdapter, +} from "../../contexts/tools/infrastructure/native-marketplace-source-reader-adapter.js"; import type { AssetProvider } from "../../kernel/ports/asset-provider.js"; import type { FileReader } from "../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../kernel/ports/file-writer.js"; import type { Logger } from "../../kernel/ports/logger.js"; import type { Prompter } from "../../kernel/ports/prompter.js"; import type { VersionReader } from "../../kernel/ports/version-reader.js"; +import type { AiToolId } from "../../kernel/tool.js"; import { CLIOutput } from "../../presentation/output.js"; import { PluginPickUseCase } from "../../presentation/prompts/plugin-pick-use-case.js"; import { SetupPluginsPromptUseCase } from "../../presentation/prompts/setup-plugins-prompt-use-case.js"; @@ -85,6 +100,7 @@ import { AuthStorage } from "../auth/auth-storage.js"; import { GhCliAdapter } from "../auth/gh-cli-adapter.js"; import { GhTokenAdapter } from "../auth/gh-token-adapter.js"; import type { CredentialStore } from "../auth/ports/credential-store.js"; +import { atomicWriteFile } from "../filesystem/atomic-write.js"; import { FileAdapter } from "../filesystem/file-adapter.js"; import { HasherAdapter } from "../filesystem/hasher-adapter.js"; import { GitAdapter } from "../git/git-adapter.js"; @@ -126,9 +142,11 @@ interface Deps extends TelemetryDeps { pluginRemoveUseCase: PluginRemoveUseCase; pluginListUseCase: PluginListUseCase; pluginUpdateUseCase: PluginUpdateUseCase; + userPluginUpdateUseCase: UserPluginUpdateUseCase; marketplaceAddUseCase: MarketplaceAddUseCase; marketplaceListUseCase: MarketplaceListUseCase; marketplaceRemoveUseCase: MarketplaceRemoveUseCase; + userMarketplaceRemoveUseCase: UserMarketplaceRemoveUseCase; marketplaceRefreshUseCase: MarketplaceRefreshUseCase; marketplaceCheckUseCase: MarketplaceCheckUseCase; userSourceReferences: UserSourceReferences; @@ -191,7 +209,7 @@ export async function createDeps( const fs = new FileAdapter(hasher, logger); const pluginDistributionReader = new PluginDistributionReaderAdapter(fs); const manifestRepo = new ManifestRepositoryAdapter(projectRoot); - const userManifestRepo = new UserManifestRepositoryAdapter(userConfigDir); + const userManifestRepo = new UserManifestRepositoryAdapter(userConfigDir, atomicWriteFile); const http = new HttpClient(); const authStorage = new AuthStorage(); const ghCliAdapter = new GhCliAdapter(); @@ -222,6 +240,17 @@ export async function createDeps( ? new InquirerPrompterAdapter() : new SilentPrompterAdapter(); const { nativePluginActivators, hostMarketplaceRegistries } = wireTools(); + const nativeSources = new Map([ + ["codex", new NativeMarketplaceSourceReaderAdapter(codexMarketplaceSourceListContract)], + ["copilot", new NativeMarketplaceSourceReaderAdapter(copilotMarketplaceSourceListContract)], + ]); + const claudeMarketplaceRegistry = hostMarketplaceRegistries.get("claude"); + if (claudeMarketplaceRegistry !== undefined) { + nativeSources.set( + "claude", + new HostRegistryMarketplaceSourceReaderAdapter(claudeMarketplaceRegistry) + ); + } // Read once, reused wherever a use case needs the scope a plugin is actually registered // at: removal, clean, and doctor's own registration check. const hostPluginRegistries = hostPluginRegistryReaders(); @@ -244,15 +273,28 @@ export async function createDeps( nativePluginActivators, hostPluginRegistries, userSourceReferences, - marketplaceRegistry + marketplaceRegistry, + userManifestRepo, + nativeSources ); const pluginListUseCase = new PluginListUseCase(manifestRepo); + const nativeHostRegistrationGate = new NativeHostRegistrationGate( + nativePluginActivators, + hostPluginRegistries, + nativeSources + ); const marketplaceRemoveUseCase = new MarketplaceRemoveUseCase( - fs, + new ProjectPluginCleanup(fs, userManifestRepo), manifestRepo, marketplaceRegistry, prompter ); + const userMarketplaceRemoveUseCase = new UserMarketplaceRemoveUseCase( + fs, + userManifestRepo, + marketplaceRegistry, + nativeHostRegistrationGate + ); // `marketplace add --overwrite` removes before it adds, and removing deletes installed // plugin files — framework work — so the orchestration belongs here rather than pulling // framework into distribution's own wiring. @@ -261,7 +303,12 @@ export async function createDeps( marketplaceTrustStore, resolveMarketplaceUseCase, prompter, - marketplaceRemoveUseCase + { + execute: (options) => + options.scope === "user" + ? userMarketplaceRemoveUseCase.execute(options) + : marketplaceRemoveUseCase.execute(options), + } ); const marketplaceCheckUseCase = new MarketplaceCheckUseCase( manifestRepo, @@ -302,7 +349,10 @@ export async function createDeps( userConfigDir, marketplaceRegisterFrameworkUseCase, userSourceReferences, - currentVersionProvider + currentVersionProvider, + hostPluginRegistries, + userManifestRepo, + nativeSources ); const pluginAddUseCase = new PluginAddUseCase( fs, @@ -312,7 +362,8 @@ export async function createDeps( hasher, logger, marketplaceRegistry, - ensureBuiltMarketplaceUseCase + ensureBuiltMarketplaceUseCase, + userManifestRepo ); const gitignoreUseCase = new GitignoreUseCase(fs); const git = new GitAdapter(fs); @@ -454,6 +505,17 @@ export async function createDeps( hasher, builtMaterializationDeps ); + const userPluginUpdateUseCase = new UserPluginUpdateUseCase( + userManifestRepo, + new UserPluginFileUpdater( + fs, + new UserPluginDistributionLoader(pluginFetcher, pluginDistributionReader), + hasher, + builtMaterializationDeps + ), + logger, + nativeHostRegistrationGate + ); const restoreUseCase = new RestoreUseCase( fs, manifestRepo, @@ -466,7 +528,7 @@ export async function createDeps( assetProvider, builtMaterializationDeps ); - const uninstallUseCase = new UninstallUseCase(fs, manifestRepo, logger); + const uninstallUseCase = new UninstallUseCase(fs, manifestRepo, logger, userManifestRepo); const statusAllUseCase = new StatusAllUseCase(statusUseCase); const restoreAllUseCase = new RestoreAllUseCase( manifestRepo, @@ -503,7 +565,9 @@ export async function createDeps( hostMarketplaceRegistries, undefined, userSourceReferences, - hostPluginRegistries + hostPluginRegistries, + userManifestRepo, + nativeSources ); const cleanUserScopeUseCase = new CleanUserScopeUseCase( fs, @@ -515,7 +579,9 @@ export async function createDeps( hostMarketplaceRegistries, homedir, userSourceReferences, - prompter + prompter, + nativeSources, + hostPluginRegistries ); const doctorAllUseCase = new DoctorAllUseCase(doctorUseCase); const listInstalledRulesUseCase = new ListInstalledRulesUseCase(fs); @@ -544,9 +610,11 @@ export async function createDeps( pluginRemoveUseCase, pluginListUseCase, pluginUpdateUseCase, + userPluginUpdateUseCase, marketplaceAddUseCase, marketplaceListUseCase, marketplaceRemoveUseCase, + userMarketplaceRemoveUseCase, marketplaceRefreshUseCase, marketplaceCheckUseCase, userSourceReferences, diff --git a/cli/src/runtime/wiring/tools.ts b/cli/src/runtime/wiring/tools.ts index 997118393..4c533bff6 100644 --- a/cli/src/runtime/wiring/tools.ts +++ b/cli/src/runtime/wiring/tools.ts @@ -4,6 +4,7 @@ import "../../contexts/tools/domain/profiles/claude/profile.js"; import "../../contexts/tools/domain/profiles/codex/profile.js"; import "../../contexts/tools/domain/profiles/copilot/profile.js"; import "../../contexts/tools/domain/profiles/cursor/profile.js"; +import "../../contexts/tools/domain/profiles/kilo/profile.js"; import "../../contexts/tools/domain/profiles/opencode/profile.js"; import "../../contexts/tools/domain/profiles/vscode/profile.js"; import type { HostMarketplaceRegistryReader } from "../../contexts/tools/domain/ports/host-marketplace-registry-reader.js"; diff --git a/cli/src/runtime/wiring/translate.ts b/cli/src/runtime/wiring/translate.ts index cc0b47211..5c7ee0c41 100644 --- a/cli/src/runtime/wiring/translate.ts +++ b/cli/src/runtime/wiring/translate.ts @@ -5,6 +5,7 @@ import "../../contexts/tools/domain/profiles/claude/profile.js"; import "../../contexts/tools/domain/profiles/codex/profile.js"; import "../../contexts/tools/domain/profiles/copilot/profile.js"; import "../../contexts/tools/domain/profiles/cursor/profile.js"; +import "../../contexts/tools/domain/profiles/kilo/profile.js"; import "../../contexts/tools/domain/profiles/opencode/profile.js"; import "../../contexts/tools/domain/profiles/vscode/profile.js"; import type { ToolBuildContract } from "../../contexts/tools/domain/build-contract.js"; diff --git a/cli/stryker.conf.json b/cli/stryker.conf.json index 252767561..c4cd509f2 100644 --- a/cli/stryker.conf.json +++ b/cli/stryker.conf.json @@ -5,6 +5,7 @@ "plugins": ["@stryker-mutator/vitest-runner"], "ignorePatterns": ["reports"], "coverageAnalysis": "perTest", + "timeoutMS": 20000, "thresholds": { "high": 80, "low": 60, diff --git a/cli/tests/architecture/catches-that-swallow.arch.test.ts b/cli/tests/architecture/catches-that-swallow.arch.test.ts index 6ea935361..c47484b03 100644 --- a/cli/tests/architecture/catches-that-swallow.arch.test.ts +++ b/cli/tests/architecture/catches-that-swallow.arch.test.ts @@ -5,7 +5,7 @@ import { readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; -import { CLI_ROOT, read, sourceFiles } from "./helpers.js"; +import { CLI_ROOT, canonicalPath, read, sourceFiles } from "./helpers.js"; /** File → how many empty catches it keeps, and why each is a deliberate best effort. */ const BASELINE: Readonly> = { @@ -78,7 +78,7 @@ function testFiles(): string[] { const full = join(dir, entry); if (statSync(full).isDirectory()) { if (entry !== "fixtures" && entry !== "snapshots") walk(full); - } else if (entry.endsWith(".ts")) out.push(relative(CLI_ROOT, full)); + } else if (entry.endsWith(".ts")) out.push(canonicalPath(relative(CLI_ROOT, full))); } }; walk(join(CLI_ROOT, "tests")); diff --git a/cli/tests/architecture/comments.arch.test.ts b/cli/tests/architecture/comments.arch.test.ts index 0886da2be..f6fcdaeaa 100644 --- a/cli/tests/architecture/comments.arch.test.ts +++ b/cli/tests/architecture/comments.arch.test.ts @@ -6,7 +6,7 @@ import { readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; -import { CLI_ROOT, read, sourceFiles } from "./helpers.js"; +import { CLI_ROOT, canonicalPath, read, sourceFiles } from "./helpers.js"; const COMMENT_LINE = /^\s*(\/\/|\/\*|\*)/; const DIRECTIVE = /biome-ignore|@ts-expect-error|eslint-disable/; @@ -20,8 +20,9 @@ const EXTERNAL_REFERENCE: readonly { readonly name: string; readonly pattern: Re { name: "pull request", pattern: /\bpull request\b|\bPR\s*#?\d/i }, ]; -/** Comment lines under `src/` and `tests/` may only decrease; a raise needs its reason here: tests/ 2960 to 2984, four guard files and their probes; 2984 to 2987, the reason the display folder is baselined over the size limit. */ -const MAX_COMMENT_LINES = { src: 4474, tests: 2987 }; +/** Comment lines under `src/` and `tests/` may only decrease; this baseline records the + * inherited session-anchor rationale, display-folder guard, and Kilo bridge contract tests. */ +const MAX_COMMENT_LINES = { src: 4488, tests: 2990 }; function testFiles(): string[] { const out: string[] = []; @@ -31,7 +32,7 @@ function testFiles(): string[] { if (statSync(full).isDirectory()) { if (entry === "fixtures" || entry === "snapshots") continue; walk(full); - } else if (entry.endsWith(".ts")) out.push(relative(CLI_ROOT, full)); + } else if (entry.endsWith(".ts")) out.push(canonicalPath(relative(CLI_ROOT, full))); } }; walk(join(CLI_ROOT, "tests")); diff --git a/cli/tests/architecture/context-boundary.arch.test.ts b/cli/tests/architecture/context-boundary.arch.test.ts index 3fa5fce17..6432af088 100644 --- a/cli/tests/architecture/context-boundary.arch.test.ts +++ b/cli/tests/architecture/context-boundary.arch.test.ts @@ -15,7 +15,7 @@ const PUBLIC_MODULES: Readonly> = { "src/contexts/tools/domain/build-contract.ts", // co-owned configuration (settings.json, .mcp.json et al.) "src/contexts/tools/domain/capabilities/mcp-capability.ts", - "src/contexts/tools/domain/mcp-exclusion.ts", + "src/contexts/tools/domain/mcp-launch-command.ts", "src/contexts/tools/domain/capabilities/settings-capability.ts", "src/contexts/tools/domain/capabilities/hooks-capability.ts", "src/contexts/tools/domain/capabilities/config-refs.ts", @@ -23,6 +23,8 @@ const PUBLIC_MODULES: Readonly> = { // ports a caller wires a concrete adapter into, or whose type it must accept "src/contexts/tools/domain/ports/file-merger.ts", "src/contexts/tools/domain/ports/native-plugin-activator.ts", + // Native host mutations need a current source witness, not only a past manifest claim. + "src/contexts/tools/domain/ports/native-marketplace-source-reader.ts", "src/contexts/tools/domain/ports/schema-validator.ts", "src/contexts/tools/domain/ports/host-plugin-registry-reader.ts", // What a host's registry says about an installed plugin: telemetry's diagnostic and diff --git a/cli/tests/architecture/helpers.ts b/cli/tests/architecture/helpers.ts index de74b7c03..12e3fbde0 100644 --- a/cli/tests/architecture/helpers.ts +++ b/cli/tests/architecture/helpers.ts @@ -3,20 +3,24 @@ * enough for a pre-commit hook and cannot be broken by runtime wiring. */ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { dirname, join, normalize, relative, resolve } from "node:path"; +import { join, posix, relative, resolve } from "node:path"; export const CLI_ROOT = resolve(import.meta.dirname, "..", ".."); export const SRC = join(CLI_ROOT, "src"); export const REPO_ROOT = resolve(CLI_ROOT, ".."); +export function canonicalPath(path: string): string { + return path.replace(/\\/g, "/"); +} + export function sourceFiles(): string[] { const out: string[] = []; const walk = (dir: string): void => { for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) walk(full); - else if (entry.endsWith(".ts")) out.push(relative(CLI_ROOT, full)); + else if (entry.endsWith(".ts")) out.push(canonicalPath(relative(CLI_ROOT, full))); } }; walk(SRC); @@ -37,7 +41,7 @@ export function pluginReadmes(): string[] { for (const entry of readdirSync(pluginsDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const readme = join(pluginsDir, entry.name, "README.md"); - if (existsSync(readme)) found.push(join("plugins", entry.name, "README.md")); + if (existsSync(readme)) found.push(canonicalPath(join("plugins", entry.name, "README.md"))); } if (found.length === 0) { throw new Error("no plugin README found — the scope of this rule is stale"); @@ -53,11 +57,12 @@ export function pluginReadmes(): string[] { */ export const INTERNAL_IMPORT = /(?:from|import)\s*\(?\s*["'](\.[^"']+|@\/[^"']+)["']/g; -function resolveImportTarget(file: string, specifier: string): string { +export function resolveImportTarget(file: string, specifier: string): string { + const target = canonicalPath(specifier); return ( - specifier.startsWith("@/") - ? `src/${specifier.slice(2)}` - : normalize(join(dirname(file), specifier)) + target.startsWith("@/") + ? `src/${target.slice(2)}` + : posix.join(posix.dirname(canonicalPath(file)), target) ).replace(/\.js$/, ".ts"); } diff --git a/cli/tests/architecture/hooks-install-from-the-root.arch.test.ts b/cli/tests/architecture/hooks-install-from-the-root.arch.test.ts new file mode 100644 index 000000000..19184030c --- /dev/null +++ b/cli/tests/architecture/hooks-install-from-the-root.arch.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CLI_ROOT } from "./helpers.js"; + +describe("git hooks are installed from the repository root only", () => { + it("no script of this package calls lefthook: a hook in a worktree runs it with GIT_DIR set, and it takes cli/ for the root", () => { + const { scripts } = JSON.parse(readFileSync(join(CLI_ROOT, "package.json"), "utf8")) as { + scripts: Record; + }; + expect(Object.entries(scripts).filter(([, command]) => /\blefthook\b/.test(command))).toEqual( + [] + ); + }); +}); diff --git a/cli/tests/architecture/mutation-covers-source.arch.test.ts b/cli/tests/architecture/mutation-covers-source.arch.test.ts index d2d1df59b..b7487098e 100644 --- a/cli/tests/architecture/mutation-covers-source.arch.test.ts +++ b/cli/tests/architecture/mutation-covers-source.arch.test.ts @@ -3,15 +3,18 @@ * have died were never generated. `mutation-scopes.json` declares the globs, the floor each * scope must hold, and what is left out, so a directory belonging to neither fails by name. */ -import { existsSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { HARNESS, scopesToRun } from "../../scripts/mutation-scopes-to-run.mjs"; import { breakVerdict, + changedArgs, + changedRanges, pruneIncremental, scoreOf, strykerArgs, + survivorsOf, } from "../../scripts/run-mutation.mjs"; import { matchesGlob, REPO_ROOT, read, sourceFiles } from "./helpers.js"; @@ -44,6 +47,12 @@ function isCovered(path: string, { scopes, excluded }: ScopeDeclaration): boolea ); } +function fixtureFiles(): string[] { + return readdirSync(join(REPO_ROOT, "cli/tests/fixtures"), { recursive: true, encoding: "utf8" }) + .filter((entry) => entry.endsWith(".ts")) + .map((entry) => `tests/fixtures/${entry.replaceAll("\\", "/")}`); +} + describe("mutation covers every source file", () => { it("no file under src/ falls outside both the scopes and the exclusions", () => { const declared = declaration(); @@ -68,7 +77,7 @@ describe("mutation covers every source file", () => { } for (const [name, { mutate }] of Object.entries(scopes)) { expect( - files.some((file) => scopeMatches(mutate, file)), + [...files, ...fixtureFiles()].some((file) => scopeMatches(mutate, file)), `scope "${name}" (${mutate}) matches no file — it would score an empty set` ).toBe(true); } @@ -220,7 +229,53 @@ describe("the guard itself", () => { }, }, }); - expect(pruned.files?.["a.ts"]?.mutants).toEqual([{ status: "Killed" }, { status: "Timeout" }]); + expect(pruned.files?.["a.ts"]?.mutants).toEqual([{ status: "Killed" }]); + }); + + it("mutates only the lines a diff adds or changes under src/, never a deletion or another file", () => { + const diff = [ + "diff --git a/src/a.ts b/src/a.ts", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -10,2 +10,3 @@ function a() {", + "@@ -20 +21 @@ function b() {", + "@@ -30,4 +31,0 @@ function c() {", + "diff --git a/src/b.ts b/src/b.ts", + "--- /dev/null", + "+++ b/src/b.ts", + "@@ -0,0 +1,5 @@", + "diff --git a/README.md b/README.md", + "+++ b/README.md", + "@@ -1 +1 @@", + ].join("\n"); + expect(changedRanges(diff)).toEqual(["src/a.ts:10-12", "src/a.ts:21-21", "src/b.ts:1-5"]); + expect(changedArgs(["src/a.ts:10-12", "src/b.ts:1-5"])).toEqual([ + "run", + "--mutate", + "src/a.ts:10-12,src/b.ts:1-5", + ]); + }); + + it("names each mutant a test left alive, with its file and line", () => { + const report = { + files: { + "src/a.ts": { + mutants: [ + { status: "Killed", mutatorName: "BooleanLiteral", location: { start: { line: 3 } } }, + { status: "Survived", mutatorName: "StringLiteral", location: { start: { line: 7 } } }, + { + status: "NoCoverage", + mutatorName: "BlockStatement", + location: { start: { line: 9 } }, + }, + ], + }, + }, + }; + expect(survivorsOf(report)).toEqual([ + "src/a.ts:7 StringLiteral (Survived)", + "src/a.ts:9 BlockStatement (NoCoverage)", + ]); }); it("fails a score under the floor and passes one on it", () => { diff --git a/cli/tests/architecture/no-shared-binary.arch.test.ts b/cli/tests/architecture/no-shared-binary.arch.test.ts index caae270cc..f2e650c1c 100644 --- a/cli/tests/architecture/no-shared-binary.arch.test.ts +++ b/cli/tests/architecture/no-shared-binary.arch.test.ts @@ -7,6 +7,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { canonicalPath } from "./helpers.js"; const CLI_ROOT = resolve(import.meta.dirname, "..", ".."); const TESTS_ROOT = join(CLI_ROOT, "tests"); @@ -31,7 +32,7 @@ describe("no test resolves a path into the shared dist/ build output", () => { it("every file under tests/ reads the e2e run's own binary, not dist/cli.js", () => { const violations = testFiles() .filter((file) => CWD_INTO_DIST.test(readFileSync(file, "utf8"))) - .map((file) => relative(CLI_ROOT, file)); + .map((file) => canonicalPath(relative(CLI_ROOT, file))); expect( violations, diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index 47d95749b..48a721161 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -10,7 +10,8 @@ const MAX_INJECTED_USE_CASES = 4; /** * Orchestrators over the limit today, each with the count its reason was written around. - * The list may only shrink, and an entry naming only the file would let one grow in silence. + * The list normally shrinks. A separately justified host witness may raise an entry, while + * the exact counts still make any unreviewed growth fail this test. */ const BASELINE: readonly { readonly path: string; readonly injected: number }[] = [ // Six checks, one per thing that can drift, reported at once: the fan-out is the feature. @@ -25,18 +26,19 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] path: "src/contexts/framework/application/restore/generate-tool-distribution-use-case.ts", injected: 9, }, + { path: "src/contexts/framework/application/restore/restore-use-case.ts", injected: 12 }, + // Host plugin registries prove ref scope/enablement; the added native source reader independently + // proves the current catalogue before these flows mutate it. Neither witness replaces the other. + { path: "src/contexts/framework/application/clean-use-case.ts", injected: 13 }, { path: "src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts", - injected: 12, + injected: 15, }, - { path: "src/contexts/framework/application/restore/restore-use-case.ts", injected: 12 }, - // Carries `hostPluginRegistries`, the host's own registry reader per `AiToolId`, so the - // scope asked for when uninstalling a ref is the one the host actually registered it at. - { path: "src/contexts/framework/application/clean-use-case.ts", injected: 11 }, - // The machine-scope counterpart of `clean-use-case.ts`, same shape and same reason. + // Machine clean also needs both witnesses: source ownership and the full host ref list, + // because unregistering an owned catalogue could still disable a foreign installed ref. { path: "src/contexts/framework/application/clean/clean-user-scope-use-case.ts", - injected: 10, + injected: 12, }, { path: "src/contexts/telemetry/application/diagnose-telemetry-use-case.ts", injected: 10 }, { @@ -47,7 +49,7 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] path: "src/contexts/framework/application/shared/setup-marketplace-registration-use-case.ts", injected: 10, }, - { path: "src/contexts/framework/application/plugin/plugin-add-use-case.ts", injected: 8 }, + { path: "src/contexts/framework/application/plugin/plugin-add-use-case.ts", injected: 9 }, { path: "src/contexts/translate/application/strategies/flat-build-strategy.ts", injected: 8 }, { path: "src/contexts/framework/application/doctor/doctor-registration-use-case.ts", @@ -93,9 +95,9 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] injected: 5, }, { path: "src/contexts/translate/application/translate-source.ts", injected: 5 }, - // Carries `clean`'s own shared-source guard as well, so `plugin remove` in one project - // cannot disable a plugin another project on the same machine still needs. - { path: "src/contexts/framework/application/plugin/plugin-remove-use-case.ts", injected: 7 }, + // Carries `clean`'s shared-source guard and the separate current-source witness so a + // targeted remove cannot disable another project's plugin or a repointed catalogue. + { path: "src/contexts/framework/application/plugin/plugin-remove-use-case.ts", injected: 9 }, ]; /** diff --git a/cli/tests/architecture/paths-select-source.arch.test.ts b/cli/tests/architecture/paths-select-source.arch.test.ts new file mode 100644 index 000000000..612c862cc --- /dev/null +++ b/cli/tests/architecture/paths-select-source.arch.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalPath, + contextOf, + importersByFile, + matchesGlob, + pluginReadmes, + resolveImportTarget, + sourceFiles, +} from "./helpers.js"; + +describe("architecture paths select the same files on every platform", () => { + it.each([ + ["src\\contexts\\tools\\domain\\tool.ts", "tools", "src/contexts/**/*.ts"], + ["src\\kernel\\errors.ts", "kernel", "src/kernel/**/*.ts"], + ["src\\presentation\\commands\\setup.ts", "presentation", "src/presentation/commands/**"], + ])("selects the explicit backslash path %s", (nativePath, context, glob) => { + const file = canonicalPath(nativePath); + expect(contextOf(file)).toBe(context); + expect(matchesGlob(glob, file)).toBe(true); + expect(matchesGlob("src/deleted-context/**", file)).toBe(false); + }); + + it.each([ + ["src\\kernel\\ports\\file-reader.ts", "../file.js"], + ["src/kernel/ports/file-reader.ts", "..\\file.js"], + ["src\\runtime\\caller.ts", "@/kernel/file.js"], + ])("resolves %s importing %s to the canonical source key", (file, specifier) => { + const known = new Set(["src/kernel/file.ts"]); + expect(known.has(resolveImportTarget(file, specifier))).toBe(true); + }); + + it("returns canonical, nonempty source and plugin scopes", () => { + const files = sourceFiles(); + const readmes = pluginReadmes(); + expect(files.length).toBeGreaterThan(0); + expect(files.every((file) => file.startsWith("src/") && !file.includes("\\"))).toBe(true); + expect(readmes.length).toBeGreaterThan(0); + expect(readmes.every((file) => /^plugins\/[^/]+\/README\.md$/.test(file))).toBe(true); + }); + + it("keeps relative-import lookups connected to the source-file keys", () => { + expect(importersByFile().get("src/kernel/file.ts")).toContain( + "src/kernel/ports/file-reader.ts" + ); + }); +}); diff --git a/cli/tests/architecture/ports-are-called.arch.test.ts b/cli/tests/architecture/ports-are-called.arch.test.ts index c05f42afe..8b060e23a 100644 --- a/cli/tests/architecture/ports-are-called.arch.test.ts +++ b/cli/tests/architecture/ports-are-called.arch.test.ts @@ -5,9 +5,9 @@ * nothing spells the name as a call, never that a real path reaches it. */ import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative, sep } from "node:path"; +import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; -import { CLI_ROOT, expectRatchet, SRC, sourceFiles } from "./helpers.js"; +import { CLI_ROOT, canonicalPath, expectRatchet, SRC, sourceFiles } from "./helpers.js"; /** "ports" must be its own path segment: a substring check also matches `supports/` and * `reports/`. */ @@ -17,8 +17,8 @@ function portFiles(): string[] { for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) walk(full); - else if (entry.endsWith(".ts") && /(^|\/)ports\//.test(full.split(sep).join("/"))) { - out.push(relative(CLI_ROOT, full)); + else if (entry.endsWith(".ts") && /(^|\/)ports\//.test(canonicalPath(full))) { + out.push(canonicalPath(relative(CLI_ROOT, full))); } } }; diff --git a/cli/tests/architecture/throwaway-profile-wiring.arch.test.ts b/cli/tests/architecture/throwaway-profile-wiring.arch.test.ts new file mode 100644 index 000000000..a2831af82 --- /dev/null +++ b/cli/tests/architecture/throwaway-profile-wiring.arch.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import mutation from "../../vitest.mutation.config.js"; +import workspace from "../../vitest.workspace.js"; + +const SETUP = "./tests/helpers/throwaway-profile.ts"; +const RUNS_SOURCE = ["unit", "integration"]; + +function projectsRunningSource( + projects: readonly unknown[] +): { name: string; globalSetup: unknown }[] { + return projects.flatMap((project) => { + const test = (project as { test?: { name?: string; globalSetup?: unknown } }).test; + return test?.name && RUNS_SOURCE.includes(test.name) + ? [{ name: test.name, globalSetup: test.globalSetup }] + : []; + }); +} + +describe("every project that runs the source sees a throwaway profile", () => { + it.each([ + ["the suite", projectsRunningSource(workspace)], + ["a mutation run", projectsRunningSource(mutation.test?.projects ?? [])], + ])( + "%s declares the throwaway-profile setup on its unit and integration projects", + (_run, projects) => { + expect(projects.map((p) => p.name).sort()).toStrictEqual(["integration", "unit"]); + for (const project of projects) expect(project.globalSetup, project.name).toContain(SETUP); + } + ); +}); diff --git a/cli/tests/architecture/tool-addition-cost.arch.test.ts b/cli/tests/architecture/tool-addition-cost.arch.test.ts index 7c04de3de..1df5051b1 100644 --- a/cli/tests/architecture/tool-addition-cost.arch.test.ts +++ b/cli/tests/architecture/tool-addition-cost.arch.test.ts @@ -66,12 +66,12 @@ const BASELINE: readonly { readonly path: string; readonly named: number }[] = [ { path: "src/contexts/tools/domain/capabilities/config-refs.ts", named: 1 }, { path: "src/contexts/tools/domain/capabilities/plugins-capability.ts", named: 3 }, { path: "src/presentation/commands/setup.ts", named: 2 }, - { path: "src/presentation/commands/translate.ts", named: 5 }, - { path: "src/presentation/prompts/menu-use-case.ts", named: 6 }, - { path: "src/runtime/assets/asset-loader.ts", named: 6 }, - { path: "src/runtime/wiring/framework.ts", named: 6 }, - { path: "src/runtime/wiring/tools.ts", named: 6 }, - { path: "src/runtime/wiring/translate.ts", named: 6 }, + { path: "src/presentation/commands/translate.ts", named: 6 }, + { path: "src/presentation/prompts/menu-use-case.ts", named: 7 }, + { path: "src/runtime/assets/asset-loader.ts", named: 7 }, + { path: "src/runtime/wiring/framework.ts", named: 7 }, + { path: "src/runtime/wiring/tools.ts", named: 7 }, + { path: "src/runtime/wiring/translate.ts", named: 7 }, // A profile cannot name the adapter reading its transcripts without putting infrastructure // in the domain, so the tool-to-reader map lives at the composition root instead. { path: "src/runtime/wiring/telemetry.ts", named: 4 }, @@ -87,12 +87,6 @@ const BASELINE: readonly { readonly path: string; readonly named: number }[] = [ // Cursor's project hooks file, named after the tool whose file it is: the directory it // writes into is Cursor's own, not a list a sixth tool joins. { path: "src/contexts/tools/domain/formats/cursor-hooks-project-merge.ts", named: 1 }, - // Flat-mode plugin extraction, keyed to the one path prefix flat materialization writes - // (`.opencode/`). A second flat-mode tool would need its own prefix check; there is one. - { - path: "src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts", - named: 1, - }, // An adapter for exactly one tool, naming its own session-state directory. { path: "src/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.ts", named: 1 }, // An adapter for exactly one tool, naming its own hook-trust config path. diff --git a/cli/tests/contexts/distribution/application/marketplace-add-use-case.unit.test.ts b/cli/tests/contexts/distribution/application/marketplace-add-use-case.unit.test.ts index 1a9446f4f..6f95e263c 100644 --- a/cli/tests/contexts/distribution/application/marketplace-add-use-case.unit.test.ts +++ b/cli/tests/contexts/distribution/application/marketplace-add-use-case.unit.test.ts @@ -5,6 +5,7 @@ import { MarketplaceAddUseCase } from "../../../../src/contexts/distribution/app import { ResolveMarketplaceUseCase } from "../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; import { PluginCatalogRepositoryAdapter } from "../../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; import { MarketplaceRemoveUseCase } from "../../../../src/contexts/framework/application/flows/marketplace-remove-use-case.js"; +import { ProjectPluginCleanup } from "../../../../src/contexts/framework/application/ownership/project-plugin-cleanup.js"; import { InvalidMarketplaceNameError, InvalidPluginManifestError, @@ -42,7 +43,12 @@ async function buildUseCase(prompter: Prompter = new KeepPrompter()) { fetchMarketplaceSource, new PluginCatalogRepositoryAdapter(fs) ); - const removeUseCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + const removeUseCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + prompter + ); const useCase = new MarketplaceAddUseCase( registry, trustStore, diff --git a/cli/tests/contexts/framework/application/clean-native-cache.integration.test.ts b/cli/tests/contexts/framework/application/clean-native-cache.integration.test.ts index f4df0480a..039562800 100644 --- a/cli/tests/contexts/framework/application/clean-native-cache.integration.test.ts +++ b/cli/tests/contexts/framework/application/clean-native-cache.integration.test.ts @@ -11,6 +11,7 @@ import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; // hostMarketplaceRegistryReaders (used by the HOME-parity test below) iterates every // AI_TOOL_IDS entry, so every profile must be registered here too. import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; import { CleanUseCase } from "../../../../src/contexts/framework/application/clean-use-case.js"; @@ -20,25 +21,48 @@ import type { HostMarketplaceRegistryReader, HostMarketplaceRegistryReading, } from "../../../../src/contexts/tools/domain/ports/host-marketplace-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; import { hostMarketplaceRegistryReaders } from "../../../../src/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.js"; +import { HostRegistryMarketplaceSourceReaderAdapter } from "../../../../src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.js"; import { AIDD_DIR } from "../../../../src/kernel/paths.js"; import type { AiToolId } from "../../../../src/kernel/tool.js"; import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; import { FakeHostMarketplaceRegistryReader } from "../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../helpers/ports/fake-host-plugin-registry-reader.js"; import { FakeNativePluginActivator } from "../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; const PROJECT_ROOT = "/test-project"; -// Not injectable: CleanUseCase.execute() resolves `nodeHomedir()` itself, so the same real -// function here — never a fixed literal — is what keeps every path below in sync with it. +// Resolve once for this in-memory fixture, then inject it into CleanUseCase: Stryker reuses +// runners while globalSetup changes HOME, so load-time cache paths and runtime paths must agree. const HOME = homedir(); const CLAUDE_CACHE_ROOT = join(HOME, ".claude", "plugins", "cache"); const CODEX_CACHE_ROOT = join(HOME, ".codex", "plugins", "cache"); const MARKETPLACE = "probe-mkt"; const REF = "plugin-a@probe-mkt"; +/** Codex's current catalogue is a literal host snapshot, not derived from the machine claim. */ +function codexHostSource(): NativeMarketplaceSourceReader { + return { + read: async () => ({ + location: "fixture Codex effective catalogue", + entries: new Map( + ["probe-mkt", "aidd-framework"].map((name) => [ + name, + { + kind: "effective-list" as const, + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + ]) + ), + }), + }; +} + /** Records every `deleteDirectory` call, so a test can prove containment refused one * without ever letting it run. */ class RecordingFileAdapter extends InMemoryFileAdapter { @@ -50,6 +74,50 @@ class RecordingFileAdapter extends InMemoryFileAdapter { } } +/** In-memory files have implicit directories; expose one real empty cache directory to prove + * a skipped host unregister cannot still make clean delete its cache shell. */ +class EmptyNativeCacheFileAdapter extends RecordingFileAdapter { + constructor(private readonly emptyCacheDir: string) { + super(); + } + + override async fileExists(path: string): Promise { + return path === this.emptyCacheDir || super.fileExists(path); + } + + override async listDirectory(path: string): Promise { + return path === this.emptyCacheDir ? [] : super.listDirectory(path); + } +} + +class LockTrackingManifestRepository extends InMemoryManifestRepository { + locked = false; + lockCalls = 0; + + async withExclusiveAccess(action: () => Promise): Promise { + this.lockCalls += 1; + this.locked = true; + try { + return await action(); + } finally { + this.locked = false; + } + } +} + +class LockAwareActivator extends FakeNativePluginActivator { + readonly removalsUnderLock: boolean[] = []; + + constructor(private readonly machineRepo: LockTrackingManifestRepository) { + super({ available: true }); + } + + override removeMarketplace(name: string, scope?: unknown, options?: { force?: boolean }): void { + this.removalsUnderLock.push(this.machineRepo.locked); + super.removeMarketplace(name, scope, options); + } +} + /** Fails `realpath` with a non-ENOENT error (EACCES) for one exact path, so a test can prove * `clean` treats that failure like containment — named and skipped — not as an abort. */ class RealpathDeniedFileAdapter extends RecordingFileAdapter { @@ -95,20 +163,37 @@ function seedManifest(toolId: "claude" | "codex", hostName: string, alias: strin manifest.addTool(toolId, "1.0.0", []); manifest.setNativeRegistrations(toolId, { binary: toolId, - marketplaces: [{ alias, hostName }], + marketplaces: [ + { + alias, + hostName, + provenance: + toolId === "claude" + ? { kind: "registry", source: "/resolved/source" } + : { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], pluginRefs: [REF], }); return manifest; } -function seedAiddMarketplaceRegistry(alias: string): InMemoryMarketplaceRegistry { +function seedAiddMarketplaceRegistry( + alias: string, + scope: "project" | "user" = "project" +): InMemoryMarketplaceRegistry { const registry = new InMemoryMarketplaceRegistry(); registry.save( PROJECT_ROOT, Marketplace.create({ name: alias, source: { kind: "local", path: "/some/built/path" }, - scope: "project", + scope, addedAt: "2026-01-01T00:00:00.000Z", }) ); @@ -119,13 +204,21 @@ function buildUseCase(deps: { fs: InMemoryFileAdapter; manifest: Manifest; activator: FakeNativePluginActivator; - binary: string; + binary: AiToolId; logger: CapturingLogger; aiddMarketplaceRegistry: InMemoryMarketplaceRegistry; hostMarketplaceRegistries?: ReadonlyMap; homeDir?: () => string; + userManifestRepo?: InMemoryManifestRepository; }): CleanUseCase { const manifestRepo = new InMemoryManifestRepository(deps.manifest, PROJECT_ROOT); + const claudeHostRegistry = deps.hostMarketplaceRegistries?.get("claude"); + const nativeReader: NativeMarketplaceSourceReader | undefined = + deps.binary === "codex" + ? codexHostSource() + : deps.binary === "claude" && claudeHostRegistry !== undefined + ? new HostRegistryMarketplaceSourceReaderAdapter(claudeHostRegistry) + : undefined; return new CleanUseCase( deps.fs, manifestRepo, @@ -135,11 +228,301 @@ function buildUseCase(deps: { deps.aiddMarketplaceRegistry, undefined, deps.hostMarketplaceRegistries ?? new Map(), - deps.homeDir + deps.homeDir ?? (() => HOME), + undefined, + new Map([ + [ + deps.binary, + new FakeHostPluginRegistryReader({ + location: "fixture host plugin registry", + refs: new Map(), + }), + ], + ]), + deps.userManifestRepo, + nativeReader === undefined ? new Map() : new Map([[deps.binary, nativeReader]]) ); } describe("clean purges a host's own plugin cache", () => { + it("project A clean keeps a non-framework Codex catalogue cache and B's exact native claim", async () => { + const fs = new RecordingFileAdapter(); + const cacheEntry = join(CODEX_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); + fs.setFile(cacheEntry, "B still uses these bytes"); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [PROJECT_ROOT, "/project-b"] }], + }); + const userRepo = new InMemoryManifestRepository(machine); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger, + aiddMarketplaceRegistry: registry, + userManifestRepo: userRepo, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.getFile(cacheEntry)).toBe("B still uses these bytes"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/project-b"] }, + ]); + expect(logger.warnMessages.join("\n")).toContain("Still needed by /project-b"); + }); + it("removes a project-labelled Codex catalogue when machine refs belong only to another host catalogue", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: "other-alias", hostName: "other-catalog" }], + pluginRefs: ["other-plugin@other-catalog"], + pluginClaims: [{ ref: "other-plugin@other-catalog", dependents: ["/project-b"] }], + }); + const userRepo = new InMemoryManifestRepository(machine); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs: new RecordingFileAdapter(), + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: registry, + userManifestRepo: userRepo, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.removedMarketplaces).toEqual([MARKETPLACE]); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: "other-plugin@other-catalog", dependents: ["/project-b"] }, + ]); + }); + it("holds the machine lock through native catalogue removal after checking its claims", async () => { + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], + pluginRefs: [], + }); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + const userRepo = new LockTrackingManifestRepository(machine); + const activator = new LockAwareActivator(userRepo); + const useCase = buildUseCase({ + fs: new RecordingFileAdapter(), + manifest: project, + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + userManifestRepo: userRepo, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.removedMarketplaces).toEqual([MARKETPLACE]); + expect(activator.removalsUnderLock).toEqual([true]); + expect(userRepo.lockCalls).toBe(1); + expect(userRepo.locked).toBe(false); + }); + it("leaves a project-labelled machine-global catalogue when no user manifest can prove it unshared", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const emptyCache = join(CODEX_CACHE_ROOT, MARKETPLACE); + const fs = new EmptyNativeCacheFileAdapter(emptyCache); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger, + aiddMarketplaceRegistry: registry, + userManifestRepo: new InMemoryManifestRepository(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.deletedDirectories).not.toContain(emptyCache); + expect(logger.warnMessages.join("\n")).toContain( + "no user manifest can prove no other project still uses it" + ); + + const noRepoActivator = new FakeNativePluginActivator({ available: true }); + const noRepoLogger = new CapturingLogger(); + const noRepoFs = new EmptyNativeCacheFileAdapter(emptyCache); + const noRepoUseCase = buildUseCase({ + fs: noRepoFs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator: noRepoActivator, + binary: "codex", + logger: noRepoLogger, + aiddMarketplaceRegistry: registry, + }); + await noRepoUseCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(noRepoActivator.removedMarketplaces).toEqual([]); + expect(noRepoFs.deletedDirectories).not.toContain(emptyCache); + expect(noRepoLogger.warnMessages.join("\n")).toContain( + "no user manifest can prove no other project still uses it" + ); + }); + it("keeps a project-labelled framework catalogue when B still has an exact native machine claim", async () => { + const framework = "aidd-framework"; + const ref = `plugin-a@${framework}`; + const project = seedManifest("codex", framework, framework); + const projectRegistrations = project.getNativeRegistrations("codex"); + if (projectRegistrations === undefined) throw new Error("fixture missing project native state"); + project.setNativeRegistrations("codex", { ...projectRegistrations, pluginRefs: [ref] }); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: framework, hostName: framework }], + pluginRefs: [ref], + pluginClaims: [{ ref, dependents: [PROJECT_ROOT, "/project-b"] }], + }); + const userRepo = new InMemoryManifestRepository(machine); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs: new RecordingFileAdapter(), + manifest: project, + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(framework), + userManifestRepo: userRepo, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref, dependents: ["/project-b"] }, + ]); + }); + it("project A clean never unregisters a project-labelled Codex catalogue still carrying B's machine ref", async () => { + const fs = new RecordingFileAdapter(); + const cacheEntry = join(CODEX_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); + fs.setFile(cacheEntry, "B still uses these bytes"); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [PROJECT_ROOT, "/project-b"] }], + }); + const userRepo = new InMemoryManifestRepository(machine); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger, + aiddMarketplaceRegistry: registry, + userManifestRepo: userRepo, + }); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(cacheEntry)).toBe("B still uses these bytes"); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/project-b"] }, + ]); + expect(logger.warnMessages.join("\n")).toContain("Still needed by /project-b"); + }); + it("does not purge B's empty Codex cache shell when a machine claim blocked host unregister", async () => { + const emptyCache = join(CODEX_CACHE_ROOT, MARKETPLACE); + const fs = new EmptyNativeCacheFileAdapter(emptyCache); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [PROJECT_ROOT, "/project-b"] }], + }); + const userRepo = new InMemoryManifestRepository(machine); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + userManifestRepo: userRepo, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.deletedDirectories).not.toContain(emptyCache); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/project-b"] }, + ]); + }); it("purges claude's cache once undoing the registration actually frees the name", async () => { const fs = new RecordingFileAdapter(); const cacheEntry = join(CLAUDE_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); @@ -169,7 +552,8 @@ describe("clean purges a host's own plugin cache", () => { expect(await fs.fileExists(cacheEntry)).toBe(false); expect(activator.removedMarketplaces).toContain(MARKETPLACE); - expect(reader.reads).toBe(1); + // Source preflight, native undo recheck, then post-removal cache confirmation. + expect(reader.reads).toBe(3); }); it("leaves claude's cache in place, and names it, when the claude CLI is not on PATH", async () => { @@ -207,7 +591,7 @@ describe("clean purges a host's own plugin cache", () => { ).toBe(true); }); - it("leaves claude's cache in place when a fresh read still names it", async () => { + it("leaves claude's cache in place without trusting a registry read after host removal fails", async () => { // `removeMarketplace` throws (host refused, or the call failed for any other // reason `bestEffort` swallows), so the registry mirror never drops the name. const fs = new RecordingFileAdapter(); @@ -234,11 +618,12 @@ describe("clean purges a host's own plugin cache", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect(await fs.fileExists(cacheEntry)).toBe(true); - expect(reader.reads).toBe(1); - expect(logger.warnMessages.some((m) => m.includes("still names it"))).toBe(true); + // Source preflight and undo recheck ran; failed removal never reached cache confirmation. + expect(reader.reads).toBe(2); + expect(logger.warnMessages.some((m) => m.includes("removal was not confirmed"))).toBe(true); }); - it("purges claude's cache when its registry does not exist at all", async () => { + it("refuses project clean when Claude's current registry does not exist", async () => { const fs = new RecordingFileAdapter(); const cacheEntry = join(CLAUDE_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); await fs.writeFile(cacheEntry, "{}"); @@ -258,12 +643,16 @@ describe("clean purges a host's own plugin cache", () => { hostMarketplaceRegistries: new Map([["claude", reader]]), }); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "absent on the host" + ); - expect(await fs.fileExists(cacheEntry)).toBe(false); + expect(fs.getFile(cacheEntry)).toBe("{}"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); }); - it("leaves claude's cache in place, and says so, when its registry could not be read", async () => { + it("refuses project clean without deleting cache when Claude's registry is unreadable", async () => { const fs = new RecordingFileAdapter(); const cacheEntry = join(CLAUDE_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); await fs.writeFile(cacheEntry, "{}"); @@ -284,19 +673,17 @@ describe("clean purges a host's own plugin cache", () => { hostMarketplaceRegistries: new Map([["claude", reader]]), }); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "EACCES" + ); - expect(await fs.fileExists(cacheEntry)).toBe(true); - expect( - logger.warnMessages.some( - (m) => - m.includes("claude: plugin cache left in place, its registry could not be read") && - m.includes("known_marketplaces.json") - ) - ).toBe(true); + expect(fs.getFile(cacheEntry)).toBe("{}"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.deletedDirectories).toEqual([]); }); - it("purges the cache under the same HOME its own registry reader resolves its file from", async () => { + it("refuses project clean under the same HOME its registry reader proves absent", async () => { // A sentinel, never this machine's real home: a cache root composed from `os.homedir()` // directly ignores the injected `homeDir` and looks under the real home instead. const SENTINEL_HOME = "/sentinel-home-clean-cache-parity"; @@ -330,14 +717,17 @@ describe("clean purges a host's own plugin cache", () => { homeDir: () => SENTINEL_HOME, }); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "absent on the host" + ); - // The real reader finds no known_marketplaces.json under a sentinel home that does not - // exist on disk — absent, not unreadable — so purging proves both halves used it. - expect(await fs.fileExists(cacheEntry)).toBe(false); + // The real reader resolves the sentinel HOME, not the user's real registry. + expect(fs.getFile(cacheEntry)).toBe("{}"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); }); - it("refuses a '..' segment in a manifest's own hostName, never consulting the registry", async () => { + it("refuses a '..' cache path after proving the current host source", async () => { const hostName = "../../../evil"; const fs = new RecordingFileAdapter(); const witness = join(CLAUDE_CACHE_ROOT, hostName); @@ -366,7 +756,7 @@ describe("clean purges a host's own plugin cache", () => { expect(await fs.fileExists(join(witness, "keep-me.txt"))).toBe(true); expect(fs.deletedDirectories).not.toContain(witness); - expect(reader.reads).toBe(0); + expect(reader.reads).toBe(1); expect(logger.warnMessages.some((m) => m.includes("does not resolve inside"))).toBe(true); }); @@ -430,7 +820,7 @@ describe("clean purges a host's own plugin cache", () => { expect(fs.deletedDirectories).toContain(join(PROJECT_ROOT, AIDD_DIR, "cache")); }); - it("touches nothing under HOME for a tool whose profile declares no pluginCacheDir", async () => { + it("detaches a shared Copilot claim locally without a host source reader or HOME deletion", async () => { const fs = new RecordingFileAdapter(); const activator = new FakeNativePluginActivator({ available: true }); const manifest = Manifest.create(); @@ -446,12 +836,14 @@ describe("clean purges a host's own plugin cache", () => { activator, binary: "copilot", logger: new CapturingLogger(), - aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE, "user"), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect(fs.deletedDirectories.some((p) => p.startsWith(HOME))).toBe(false); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); }); it("purges under the catalog's own hostName, never the project's local alias", async () => { @@ -495,6 +887,7 @@ describe("clean purges a host's own plugin cache", () => { binary: "codex", logger: new CapturingLogger(), aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + userManifestRepo: new InMemoryManifestRepository(Manifest.create()), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); @@ -516,6 +909,7 @@ describe("clean purges a host's own plugin cache", () => { binary: "codex", logger, aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + userManifestRepo: new InMemoryManifestRepository(Manifest.create()), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); @@ -540,6 +934,7 @@ describe("clean purges a host's own plugin cache", () => { binary: "codex", logger, aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + userManifestRepo: new InMemoryManifestRepository(Manifest.create()), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); diff --git a/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts b/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts index 38d69476d..0aeb7546e 100644 --- a/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -7,12 +8,15 @@ import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; -import { CleanUseCase } from "../../../../src/contexts/framework/application/clean-use-case.js"; +import { CleanUserScopeUseCase } from "../../../../src/contexts/framework/application/clean/clean-user-scope-use-case.js"; +import { CleanUseCase as ProductionCleanUseCase } from "../../../../src/contexts/framework/application/clean-use-case.js"; import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { UserSourceReferencesAdapter } from "../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; import { cursorProjectHooksScriptDir } from "../../../../src/contexts/tools/domain/formats/cursor-hooks-project-merge.js"; +import type { HostPluginRegistryReader } from "../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; import { FileHash, InstallationFile } from "../../../../src/kernel/file.js"; import type { AiToolId, ToolId } from "../../../../src/kernel/tool.js"; @@ -26,6 +30,75 @@ import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-ma import { RecordingPrompter } from "../../../helpers/ports/recording-prompter.js"; const PROJECT_ROOT = "/test-project"; +const md5 = (content: string): string => createHash("md5").update(content, "utf-8").digest("hex"); + +/** Test-double host catalogues, deliberately seeded without consulting the manifest claim. */ +function nativeSourcesOnHost(): ReadonlyMap { + const codexLike: NativeMarketplaceSourceReader = { + read: async () => ({ + location: "fixture native host catalogue", + entries: new Map( + ["aidd-framework", "user-mkt", "other-host"].map((name) => [ + name, + { + kind: "effective-list" as const, + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + ]) + ), + }), + }; + const claude: NativeMarketplaceSourceReader = { + read: async () => ({ + location: "fixture Claude host registry", + entries: new Map([ + ["aidd-framework", { kind: "registry" as const, source: "/some/built/path" }], + ]), + }), + }; + return new Map([ + ["codex", codexLike], + ["copilot", codexLike], + ["cursor", codexLike], + ["claude", claude], + ]); +} + +/** Independently parsed test registry: no foreign refs unless a test seeds them explicitly. */ +function nativeRefsOnHost(): ReadonlyMap { + const empty = new FakeHostPluginRegistryReader({ + location: "fixture native plugin registry", + refs: new Map(), + }); + return new Map([ + ["codex", empty], + ["copilot", empty], + ["claude", empty], + ["cursor", empty], + ]); +} + +class CleanWithKnownHostSource extends ProductionCleanUseCase { + constructor(...args: ConstructorParameters) { + super( + args[0], + args[1], + args[2], + args[3], + args[4], + args[5], + args[6], + args[7], + args[8], + args[9], + args[10]?.size ? args[10] : nativeRefsOnHost(), + args[11], + args[12] ?? nativeSourcesOnHost() + ); + } +} /** Records every path `deleteFile` is called with, so a test can prove where a plugin's * file actually got deleted from without inspecting private use-case state. */ @@ -50,7 +123,7 @@ describe("clean", () => { const gitignorePath = join(PROJECT_ROOT, ".gitignore"); await deps.fs.writeFile(gitignorePath, "node_modules/\n.aidd/cache/\ndist/\n"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -71,7 +144,7 @@ describe("clean", () => { const gitignorePath = join(PROJECT_ROOT, ".gitignore"); await deps.fs.writeFile(gitignorePath, "node_modules/\n.aidd/cache/\naidd_docs/runs/\ndist/\n"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -93,7 +166,7 @@ describe("clean", () => { const gitignorePath = join(PROJECT_ROOT, ".gitignore"); await deps.fs.writeFile(gitignorePath, "node_modules/\n"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -112,7 +185,7 @@ describe("clean", () => { const userFile = join(PROJECT_ROOT, "my-custom-file.txt"); await deps.fs.writeFile(userFile, "user content"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -132,7 +205,7 @@ describe("clean", () => { await deps.fs.writeFile(configPath, '{"telemetry":{"enabled":true}}'); await deps.fs.writeFile(cacheFile, "{}"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -152,7 +225,7 @@ describe("clean", () => { const pluginCacheFile = join(PROJECT_ROOT, ".aidd", "plugin-cache", "some-plugin", "x.json"); await deps.fs.writeFile(pluginCacheFile, "{}"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -171,7 +244,7 @@ describe("clean", () => { const cacheFile = join(PROJECT_ROOT, ".aidd", "cache", "built", "leftover.json"); await deps.fs.writeFile(cacheFile, "{}"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -190,7 +263,7 @@ describe("clean", () => { const registry = join(PROJECT_ROOT, ".aidd", "marketplaces.json"); await deps.fs.writeFile(registry, JSON.stringify({ version: 1, marketplaces: [] })); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -213,7 +286,7 @@ describe("clean", () => { await deps.fs.writeFile(config, JSON.stringify({ telemetry: { enabled: true } })); await deps.fs.writeFile(registry, JSON.stringify({ version: 1, marketplaces: [] })); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -225,7 +298,7 @@ describe("clean", () => { expect(deps.fs.has(config)).toBe(true); }); - it("deletes a user-scope (cursor) plugin's file from its resolved home directory, not projectRoot", async () => { + it("leaves a user-scope Cursor plugin's files to explicit user cleanup", async () => { const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); manifest.addPlugin( @@ -242,7 +315,7 @@ describe("clean", () => { const fs = new RecordingFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -252,7 +325,7 @@ describe("clean", () => { expect( fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", PLUGIN_KEY))) - ).toBe(true); + ).toBe(false); expect(fs.deletedPaths).not.toContain(join(PROJECT_ROOT, PLUGIN_KEY)); }); @@ -274,7 +347,7 @@ describe("clean", () => { const fs = new RecordingFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -298,12 +371,57 @@ describe("clean", () => { manifest.addTool("codex", "1.0.0", []); manifest.setNativeRegistrations("codex", { binary: BINARY, - marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], + pluginRefs: [REF], + }); + return manifest; + } + + function seedClaudeNativeRegistrations(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { kind: "registry", source: "/some/built/path" }, + }, + ], pluginRefs: [REF], }); return manifest; } + /** Explicit host ref evidence for Claude positives, separate from the AIDD manifest. */ + function claudeRefOnHost( + scope?: "project" | "user" + ): ReadonlyMap { + return new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "fixture Claude installed-plugin registry", + refs: new Map([ + [REF, scope === undefined ? { enabled: true } : { enabled: true, scope }], + ]), + }), + ], + ]); + } + function seedMarketplaceRegistry(): InMemoryMarketplaceRegistry { const registry = new InMemoryMarketplaceRegistry(); registry.save( @@ -318,12 +436,456 @@ describe("clean", () => { return registry; } - it("uninstalls the registered plugin ref and removes the marketplace it came from", async () => { + describe("machine claims survive project cleanup", () => { + it("names shared-source dependents only on refs belonging to that source", async () => { + const manifest = seedManifestWithNativeRegistrations(); + manifest.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: [ + ...(manifest.getNativeRegistrations("codex")?.marketplaces ?? []), + { + alias: "other", + hostName: "other-host", + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], + pluginRefs: [REF, "unclaimed@other-host"], + }); + const fs = new InMemoryFileAdapter(); + for (const root of [PROJECT_ROOT, "/project-b", "/project-c"]) + fs.setFile(join(root, "marker"), "project"); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + for (const root of [PROJECT_ROOT, "/project-b", "/project-c"]) + await references.addReference("1.0.0", root); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "other", + source: { kind: "local", path: "/some/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + references, + undefined, + new InMemoryManifestRepository(Manifest.create(), "/fake-home") + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual(["other-host"]); + expect(await references.listAllReferencingProjects()).toEqual(["/project-b", "/project-c"]); + expect(logger.warnMessages.slice(0, 2)).toEqual([ + `codex: '${REF}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean. Still needed by /project-b, /project-c.`, + "codex: 'unclaimed@other-host' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.", + ]); + }); + + it.each([ + { others: [], suffix: "" }, + { + others: ["/project-b", "/project-c"], + suffix: " Still needed by /project-b, /project-c.", + }, + ])( + "keeps canonical refs and reports remaining dependents: $others", + async ({ others, suffix }) => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), "project"); + const cachePath = "/fake-home/.codex/plugins/cache/aidd-framework/plugin.json"; + fs.setFile(cachePath, "cache"); + const extraRef = "another@aidd-framework"; + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: manifest.getNativeRegistrations("codex")?.marketplaces ?? [], + pluginRefs: [REF, extraRef], + pluginClaims: [ + { ref: REF, dependents: [PROJECT_ROOT, ...others] }, + { ref: extraRef, dependents: [PROJECT_ROOT, ...others] }, + { ref: "unrelated@other-host", dependents: ["/unrelated"] }, + ], + }); + const machineRepo = new InMemoryManifestRepository(machine, "/fake-home"); + const projectRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const hostRefs = new FakeHostPluginRegistryReader({ + location: "Codex machine refs", + refs: new Map([ + [REF, { enabled: true }], + [extraRef, { enabled: true }], + ]), + }); + const useCase = new CleanWithKnownHostSource( + fs, + projectRepo, + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry(), + undefined, + new Map(), + () => "/fake-home", + undefined, + new Map([["codex", hostRefs]]), + machineRepo + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(cachePath)).toBe("cache"); + expect(projectRepo.getCurrent()).toBeNull(); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: others }, + { ref: extraRef, dependents: [PROJECT_ROOT, ...others] }, + { ref: "unrelated@other-host", dependents: ["/unrelated"] }, + ]); + expect(logger.warnMessages).toEqual([ + `codex: '${REF}' is an AIDD-owned machine plugin ref — left enabled; project claim detached after local clean.${suffix}`, + `codex: '${MARKETPLACE}' carries AIDD-owned machine plugin refs — left registered for explicit user-scope removal.${suffix}`, + `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join("/fake-home", ".codex", "plugins", "cache", MARKETPLACE)}`, + ]); + } + ); + + it.each([undefined, { binary: BINARY, marketplaces: [], pluginRefs: [] }])( + "removes an empty project catalogue when the machine has no matching claims: %j", + async (machineRegistrations) => { + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + if (machineRegistrations !== undefined) + machine.setNativeRegistrations("codex", machineRegistrations); + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(seedManifestWithNativeRegistrations(), PROJECT_ROOT), + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry(), + undefined, + new Map(), + () => "/fake-home", + undefined, + undefined, + new InMemoryManifestRepository(machine, "/fake-home") + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.removedMarketplaces).toEqual([MARKETPLACE]); + expect(logger.warnMessages).toEqual([ + `codex: '${REF}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.`, + ]); + } + ); + }); + + describe("unproven Claude refs in a shared source", () => { + async function sharedClaudeRegistry(): Promise { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + return registry; + } + + it.each([ + { entries: new Map(), reason: "Reconcile manually." }, + { + entries: new Map([[MARKETPLACE, { kind: "registry" as const, source: "/foreign" }]]), + reason: `Catalogue '${MARKETPLACE}': current host source differs from AIDD's recorded source; reconcile manually.`, + }, + ])( + "leaves the ref enabled when its host source is unproven: $reason", + async ({ entries, reason }) => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => ({ location: "Claude source fixture", entries }), + }; + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(seedClaudeNativeRegistrations(), PROJECT_ROOT), + logger, + new GitignoreUseCase(fs), + new Map([["claude", activator]]), + await sharedClaudeRegistry(), + undefined, + new Map(), + () => "/fake-home", + undefined, + claudeRefOnHost("project"), + undefined, + new Map([["claude", source]]) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(logger.warnMessages[0]).toBe( + `claude: '${REF}' left enabled because its current host catalogue is unproven. ${reason}` + ); + } + ); + + it("warns about a ref whose host catalogue was not recorded", async () => { + const manifest = seedClaudeNativeRegistrations(); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: manifest.getNativeRegistrations("claude")?.marketplaces ?? [], + pluginRefs: ["orphan@unrecorded"], + }); + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + logger, + new GitignoreUseCase(fs), + new Map([["claude", activator]]), + await sharedClaudeRegistry() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(logger.warnMessages[0]).toBe( + "claude: 'orphan@unrecorded' has no recorded host catalogue — left enabled." + ); + }); + + it.each([false, true])( + "only uninstalls when all other host refs have canonical machine claims: %s", + async (claimed) => { + const extraRef = "machine-plugin@aidd-framework"; + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), "project"); + const machine = Manifest.create(); + machine.addTool("claude", "1.0.0", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [], + pluginRefs: [extraRef], + pluginClaims: claimed ? [{ ref: extraRef, dependents: ["/other-project"] }] : [], + }); + const hostRefs = new FakeHostPluginRegistryReader({ + location: "Claude refs fixture", + refs: new Map([ + [REF, { enabled: true, scope: "project" }], + [extraRef, { enabled: true, scope: "user" }], + ]), + }); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(seedClaudeNativeRegistrations(), PROJECT_ROOT), + logger, + new GitignoreUseCase(fs), + new Map([["claude", activator]]), + await sharedClaudeRegistry(), + undefined, + new Map(), + () => "/fake-home", + undefined, + new Map([["claude", hostRefs]]), + new InMemoryManifestRepository(machine, "/fake-home") + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual(claimed ? [REF] : []); + expect(activator.removedMarketplaces).toEqual([]); + if (claimed) + expect( + logger.warnMessages.some((message) => message.includes("foreign host ref")) + ).toBe(false); + else + expect(logger.warnMessages[0]).toBe( + `claude: '${REF}' left enabled; Error: Catalogue '${MARKETPLACE}' includes foreign host ref '${extraRef}'; no host mutation made.` + ); + } + ); + }); + + it("refuses a repointed native catalogue before shared reference or local deletion", async () => { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { + kind: "effective-list", + root: "/aidd/built", + sourceType: "local", + source: "/aidd/built", + }, + }, + ], + pluginRefs: [REF], + }); + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), "keep"); + fs.setFile("/other-project/marker", "keep"); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + await references.addReference("1.0.0", "/other-project"); + const activator = new FakeNativePluginActivator({ available: true }); + const sourceReader: NativeMarketplaceSourceReader = { + read: async () => ({ + location: "fake codex host", + entries: new Map([ + [ + MARKETPLACE, + { + kind: "effective-list" as const, + root: "/foreign/built", + sourceType: "local", + source: "/foreign/built", + }, + ], + ]), + }), + }; + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry(), + undefined, + new Map(), + () => "/fake-home", + references, + new Map(), + undefined, + new Map([["codex", sourceReader]]) + ); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "differs" + ); + + expect(await references.listAllReferencingProjects()).toContain(PROJECT_ROOT); + expect(await references.listAllReferencingProjects()).toContain("/other-project"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(join(PROJECT_ROOT, "marker"))).toBe("keep"); + }); + + it("refuses a foreign enabled ref inside an otherwise AIDD-owned catalogue", async () => { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], + pluginRefs: [REF], + }); + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), "keep"); + fs.setFile("/other-project/marker", "keep"); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + await references.addReference("1.0.0", "/other-project"); + const activator = new FakeNativePluginActivator({ available: true }); + const hostRefs = new FakeHostPluginRegistryReader({ + location: "fake codex plugins", + refs: new Map([ + [REF, { enabled: true }], + ["foreign@aidd-framework", { enabled: true }], + ]), + }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry(), + undefined, + new Map(), + () => "/fake-home", + references, + new Map([["codex", hostRefs]]) + ); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "foreign" + ); + + expect(await references.listAllReferencingProjects()).toContain(PROJECT_ROOT); + expect(await references.listAllReferencingProjects()).toContain("/other-project"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(join(PROJECT_ROOT, "marker"))).toBe("keep"); + }); + + it("leaves an unclaimed machine-global ref enabled while removing its empty project catalogue", async () => { const manifest = seedManifestWithNativeRegistrations(); const fs = new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: true }); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -334,11 +896,12 @@ describe("clean", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).toEqual([REF]); + expect(activator.uninstalledPlugins).toEqual([]); expect(activator.removedMarketplaces).toEqual([MARKETPLACE]); + expect(manifestRepo.getCurrent()).toBeNull(); }); - it("leaves a machine-scope marketplace registered — every other project on this machine shares it — while still uninstalling this project's own plugin ref", async () => { + it("leaves both an unclaimed machine-global ref and its shared catalogue registered", async () => { const manifest = seedManifestWithNativeRegistrations(); const fs = new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); @@ -355,7 +918,7 @@ describe("clean", () => { ); const logger = new CapturingLogger(); const HOME = "/fake-home"; - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -369,8 +932,9 @@ describe("clean", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).toEqual([REF]); + expect(activator.uninstalledPlugins).toEqual([]); expect(activator.removedMarketplaces).toEqual([]); + expect(logger.warnMessages.join("\n")).toContain("no canonical machine claim"); // The message must name all three survivors: the host's own registration, // `userConfigDir()/marketplaces.json`, and the tool's own plugin cache. const message = logger.warnMessages.find( @@ -402,7 +966,13 @@ describe("clean", () => { ); manifest.setNativeRegistrations("claude", { binary: CLAUDE_BINARY, - marketplaces: [{ alias: CLAUDE_MARKETPLACE, hostName: CLAUDE_MARKETPLACE }], + marketplaces: [ + { + alias: CLAUDE_MARKETPLACE, + hostName: CLAUDE_MARKETPLACE, + provenance: { kind: "registry", source: "/some/built/path" }, + }, + ], pluginRefs: [CLAUDE_REF], }); return manifest; @@ -432,7 +1002,7 @@ describe("clean", () => { available: true, installedAtScope: new Map([[CLAUDE_REF, "user"]]), }); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -464,7 +1034,7 @@ describe("clean", () => { }), ], ]); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -514,7 +1084,7 @@ describe("clean", () => { const userSourceReferences = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); await userSourceReferences.addReference("1.0.0", OTHER_PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -540,6 +1110,62 @@ describe("clean", () => { expect((await registry.list(OTHER_PROJECT_ROOT)).map((m) => m.name)).toContain(MARKETPLACE); }); + it("keeps its shared source claim when gitignore cleanup fails", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const references = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); + await references.addReference("1.0.0", PROJECT_ROOT); + await references.addReference("1.0.0", OTHER_PROJECT_ROOT); + const gitignore = new GitignoreUseCase(fs); + gitignore.remove = async () => { + throw new Error("gitignore cleanup failed"); + }; + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + gitignore, + new Map([[BINARY, new FakeNativePluginActivator({ available: true })]]), + registry, + undefined, + new Map(), + () => "/fake-home", + references + ); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "gitignore cleanup failed" + ); + expect(await references.listAllReferencingProjects()).toEqual([ + PROJECT_ROOT, + OTHER_PROJECT_ROOT, + ]); + const userClean = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(), + new CapturingLogger(), + registry, + () => "/fake-home/.config/aidd", + new Map(), + new Map(), + () => "/fake-home", + references + ); + await expect(userClean.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "the shared framework source" + ); + }); + it("names how many other projects still reference the source", async () => { const manifest = seedManifestWithNativeRegistrations(); const fs = new InMemoryFileAdapter(); @@ -559,7 +1185,7 @@ describe("clean", () => { await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); await userSourceReferences.addReference("1.0.0", OTHER_PROJECT_ROOT); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -606,7 +1232,7 @@ describe("clean", () => { await userSourceReferences.addReference("1.0.0", OTHER_PROJECT_ROOT); await userSourceReferences.addReference("1.0.0", THIRD_PROJECT_ROOT); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -644,7 +1270,7 @@ describe("clean", () => { const userSourceReferences = seedReferences(fs, [PROJECT_ROOT]); await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -684,7 +1310,7 @@ describe("clean", () => { const userSourceReferences = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); await userSourceReferences.addReference("1.0.0", OTHER_PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -727,7 +1353,7 @@ describe("clean", () => { const userSourceReferences = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); await userSourceReferences.addReference("2.0.0", OTHER_PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -769,7 +1395,7 @@ describe("clean", () => { await userSourceReferences.addReference("0.9.0", PROJECT_ROOT); await userSourceReferences.addReference("0.9.0", OTHER_PROJECT_ROOT); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -816,7 +1442,7 @@ describe("clean", () => { () => "/fake-home/.config/aidd" ); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -857,7 +1483,7 @@ describe("clean", () => { () => "/fake-home/.config/aidd" ); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -883,7 +1509,7 @@ describe("clean", () => { const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: false }); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -909,18 +1535,23 @@ describe("clean", () => { }); it("uninstalls the plugin ref before removing the marketplace, for the same tool", async () => { - const manifest = seedManifestWithNativeRegistrations(); + const manifest = seedClaudeNativeRegistrations(); const fs = new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const calls: string[] = []; const activator = new OrderRecordingActivator(calls); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), new GitignoreUseCase(fs), - new Map([[BINARY, activator]]), - seedMarketplaceRegistry() + new Map([["claude", activator]]), + seedMarketplaceRegistry(), + undefined, + new Map(), + undefined, + undefined, + claudeRefOnHost("project") ); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); @@ -946,7 +1577,7 @@ describe("clean", () => { const activator = new AssertingActivator(async () => { builtTreeExistedAtRemoveMarketplace = fs.has(builtPath); }); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -965,7 +1596,7 @@ describe("clean", () => { const fs = new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: true }); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -997,7 +1628,18 @@ describe("clean", () => { manifest.addTool("codex", "1.0.0", []); manifest.setNativeRegistrations("codex", { binary: BINARY, - marketplaces: [{ alias: ALIAS, hostName: HOST_NAME }], + marketplaces: [ + { + alias: ALIAS, + hostName: HOST_NAME, + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], pluginRefs: [], }); const fs = new InMemoryFileAdapter(); @@ -1013,13 +1655,19 @@ describe("clean", () => { addedAt: "2026-01-01T00:00:00.000Z", }) ); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), new GitignoreUseCase(fs), new Map([[BINARY, activator]]), - registry + registry, + undefined, + undefined, + undefined, + undefined, + undefined, + new InMemoryManifestRepository(Manifest.create()) ); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); @@ -1038,8 +1686,9 @@ describe("clean", () => { logger: CapturingLogger; activators: ReadonlyMap; registry: InMemoryMarketplaceRegistry | undefined; - }): CleanUseCase { - return new CleanUseCase( + hostPluginRegistries?: ReadonlyMap; + }): CleanWithKnownHostSource { + return new CleanWithKnownHostSource( deps.fs, new InMemoryManifestRepository(deps.manifest, PROJECT_ROOT), deps.logger, @@ -1048,7 +1697,9 @@ describe("clean", () => { deps.registry, undefined, new Map(), - () => HOME + () => HOME, + undefined, + deps.hostPluginRegistries ); } @@ -1071,7 +1722,7 @@ describe("clean", () => { ]); }); - it("leaves a registration in place when its alias is no longer registered here, still uninstalling the plugin ref", async () => { + it("leaves a lost-alias registration and its unclaimed machine-global ref untouched", async () => { const fs = new InMemoryFileAdapter(); fs.setFile(join(CODEX_CACHE, MARKETPLACE, "leftover.json"), "{}"); const logger = new CapturingLogger(); @@ -1096,9 +1747,10 @@ describe("clean", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).toStrictEqual([REF]); + expect(activator.uninstalledPlugins).toStrictEqual([]); expect(activator.removedMarketplaces).toStrictEqual([]); expect(logger.warnMessages).toStrictEqual([ + `codex: '${REF}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.`, `codex: '${MARKETPLACE}' is no longer a registered marketplace here, so its scope cannot be resolved — its codex registration was left in place.`, `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, ]); @@ -1118,8 +1770,10 @@ describe("clean", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.uninstalledPlugins).toStrictEqual([]); expect(activator.removedMarketplaces).toStrictEqual([]); expect(logger.warnMessages).toStrictEqual([ + `codex: '${REF}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.`, `codex: '${MARKETPLACE}' is no longer a registered marketplace here, so its scope cannot be resolved — its codex registration was left in place.`, `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, ]); @@ -1139,7 +1793,9 @@ describe("clean", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.uninstalledPlugins).toStrictEqual([]); expect(logger.warnMessages).toStrictEqual([ + `codex: '${REF}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.`, `codex marketplace remove '${MARKETPLACE}' failed: marketplace remove ${MARKETPLACE} failed: '${MARKETPLACE}' is not configured or installed`, `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, ]); @@ -1153,29 +1809,31 @@ describe("clean", () => { failOnUninstall: [REF], }); const useCase = buildUseCase({ - manifest: seedManifestWithNativeRegistrations(), + manifest: seedClaudeNativeRegistrations(), fs, logger, - activators: new Map([[BINARY, activator]]), + activators: new Map([["claude", activator]]), registry: seedMarketplaceRegistry(), + hostPluginRegistries: claudeRefOnHost(), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect(activator.uninstalledPluginScopes).toStrictEqual(["project", "user"]); expect(logger.warnMessages).toStrictEqual([ - `codex plugin uninstall '${REF}' failed: plugin \`${REF}\` is not installed`, + `claude plugin uninstall '${REF}' failed: plugin \`${REF}\` is not installed`, ]); }); it("propagates an activator failure that is not the host refusing", async () => { const fs = new InMemoryFileAdapter(); const useCase = buildUseCase({ - manifest: seedManifestWithNativeRegistrations(), + manifest: seedClaudeNativeRegistrations(), fs, logger: new CapturingLogger(), - activators: new Map([[BINARY, new CrashingUninstallActivator()]]), + activators: new Map([["claude", new CrashingUninstallActivator()]]), registry: seedMarketplaceRegistry(), + hostPluginRegistries: claudeRefOnHost("project"), }); await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( @@ -1202,7 +1860,18 @@ describe("clean", () => { manifest.addTool(toolId, "1.0.0", []); manifest.setNativeRegistrations(toolId, { binary, - marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], pluginRefs: [], }); return manifest; @@ -1226,6 +1895,115 @@ describe("clean", () => { ]); }); + it("detaches project A without host mutation when a shared Copilot source is unreadable", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), ""); + fs.setFile(join("/other-project", "marker"), ""); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + await references.addReference("1.0.0", "/other-project"); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(seedRegistrations("copilot", "copilot"), PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([["copilot", activator]]), + seedSharedRegistry(), + undefined, + new Map(), + () => "/fake-home", + references, + undefined, + undefined, + new Map() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.manifestFound).toBe(true); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.uninstalledPlugins).toEqual([]); + expect(await references.listAllReferencingProjects()).toEqual(["/other-project"]); + }); + + it("does not uninstall an unclaimed shared ref when its current source is unreadable", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), ""); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + const manifest = seedRegistrations("copilot", "copilot"); + manifest.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: manifest.getNativeRegistrations("copilot")?.marketplaces ?? [], + pluginRefs: [`demo@${MARKETPLACE}`], + }); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([["copilot", activator]]), + seedSharedRegistry(), + undefined, + new Map(), + () => "/fake-home", + references, + undefined, + undefined, + new Map() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(await references.listAllReferencingProjects()).toEqual([]); + }); + + it("leaves a machine-global ref enabled without a canonical claim even when host source is proven", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), ""); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + const manifest = seedRegistrations("codex", "codex"); + manifest.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: manifest.getNativeRegistrations("codex")?.marketplaces ?? [], + pluginRefs: [`demo@${MARKETPLACE}`], + }); + const activator = new FakeNativePluginActivator({ available: true }); + const hostRefs = new Map([ + [ + "codex", + new FakeHostPluginRegistryReader({ + location: "Codex user-global registry", + refs: new Map([[`demo@${MARKETPLACE}`, { enabled: true }]]), + }), + ], + ]); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([["codex", activator]]), + seedSharedRegistry(), + undefined, + new Map(), + () => "/fake-home", + references, + hostRefs + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(await references.listAllReferencingProjects()).toEqual([]); + }); + it("names no cache for a shared marketplace at a tool that drives no native CLI (cursor)", async () => { const fs = new InMemoryFileAdapter(); const logger = new CapturingLogger(); @@ -1355,18 +2133,18 @@ describe("clean", () => { }); }); - describe("the scope the manifest recorded for a plugin ref, at cursor, the one host admitting a user-scope record", () => { - const CURSOR_REF = "aidd-context@aidd-framework"; + describe("the scope Claude asks for a project-owned plugin ref", () => { + const CLAUDE_REF = "aidd-context@aidd-framework"; - function seedCursorManifest( + function seedClaudeScopeManifest( marketplaces: ReadonlyArray<{ alias: string; hostName: string }>, plugins: ReadonlyArray<{ name: string; scope: "project" | "user"; marketplace: string }> ): Manifest { const manifest = Manifest.create(); - manifest.addTool("cursor", "1.0.0", []); + manifest.addTool("claude", "1.0.0", []); for (const plugin of plugins) { manifest.addPlugin( - "cursor", + "claude", InstalledPlugin.fromMetadata( plugin.name, "1.0.0", @@ -1377,10 +2155,14 @@ describe("clean", () => { ) ); } - manifest.setNativeRegistrations("cursor", { - binary: "cursor", - marketplaces: [...marketplaces], - pluginRefs: [CURSOR_REF], + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: marketplaces.map(({ alias, hostName }) => ({ + alias, + hostName, + provenance: { kind: "registry", source: "/some/built/path" }, + })), + pluginRefs: [CLAUDE_REF], }); return manifest; } @@ -1389,14 +2171,14 @@ describe("clean", () => { const fs = new InMemoryFileAdapter(); const activator = new FakeNativePluginActivator({ available: true, - installedAtScope: new Map([[CURSOR_REF, "user"]]), + installedAtScope: new Map([[CLAUDE_REF, "user"]]), }); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(manifest, PROJECT_ROOT), new CapturingLogger(), new GitignoreUseCase(fs), - new Map([["cursor", activator]]), + new Map([["claude", activator]]), seedMarketplaceRegistry() ); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); @@ -1405,7 +2187,7 @@ describe("clean", () => { it("tries the user scope first when that is what the manifest recorded for the plugin behind the ref, found by its own alias among several", async () => { const scopes = await uninstallScopesFor( - seedCursorManifest( + seedClaudeScopeManifest( [ { alias: "other", hostName: "other-host" }, { alias: MARKETPLACE, hostName: MARKETPLACE }, @@ -1422,7 +2204,7 @@ describe("clean", () => { it("tries the project scope first when the plugin's alias matches no recorded marketplace", async () => { const scopes = await uninstallScopesFor( - seedCursorManifest( + seedClaudeScopeManifest( [{ alias: MARKETPLACE, hostName: MARKETPLACE }], [{ name: "aidd-context", scope: "user", marketplace: "unknown-alias" }] ) @@ -1433,7 +2215,7 @@ describe("clean", () => { it("tries the project scope first when the manifest records no plugin at all for the ref", async () => { const scopes = await uninstallScopesFor( - seedCursorManifest([{ alias: MARKETPLACE, hostName: MARKETPLACE }], []) + seedClaudeScopeManifest([{ alias: MARKETPLACE, hostName: MARKETPLACE }], []) ); expect(scopes).toStrictEqual(["project", "user"]); @@ -1442,7 +2224,7 @@ describe("clean", () => { it("previews no shared-source fact when the references port is wired in but no marketplace registry is", async () => { const fs = new InMemoryFileAdapter(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(seedManifestWithNativeRegistrations(), PROJECT_ROOT), new CapturingLogger(), @@ -1486,7 +2268,7 @@ describe("clean", () => { const settingsLocalPath = join(PROJECT_ROOT, ".claude", "settings.local.json"); await deps.fs.writeFile(settingsLocalPath, "{}"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -1499,6 +2281,9 @@ describe("clean", () => { it("deletes .cursor/hooks.json entirely once unmerging leaves it empty, and its script directory", async () => { const pluginName = "aidd-context"; + const scriptMarker = cursorProjectHooksScriptDir(pluginName); + const command = `./${scriptMarker}run.js`; + const scriptContent = "// hook script"; const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); manifest.addPlugin( @@ -1510,23 +2295,26 @@ describe("clean", () => { strict: false, files: {}, scope: "project", + projectHooks: { + entries: [{ event: "PreToolUse", command, digest: md5(JSON.stringify({ command })) }], + scripts: { [`${scriptMarker}run.js`]: md5(scriptContent) }, + }, }) ); const fs = new InMemoryFileAdapter(); const hooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); - const scriptMarker = cursorProjectHooksScriptDir(pluginName); await fs.writeFile( hooksPath, JSON.stringify({ version: 1, - hooks: { PreToolUse: [{ command: `./${scriptMarker}run.js` }] }, + hooks: { PreToolUse: [{ command }] }, }) ); const scriptPath = join(PROJECT_ROOT, scriptMarker, "run.js"); - await fs.writeFile(scriptPath, "// hook script"); + await fs.writeFile(scriptPath, scriptContent); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -1541,6 +2329,8 @@ describe("clean", () => { it("keeps .cursor/hooks.json when another plugin's entries remain in it", async () => { const pluginName = "aidd-context"; const otherPluginName = "aidd-dev"; + const scriptMarker = cursorProjectHooksScriptDir(pluginName); + const command = `./${scriptMarker}run.js`; const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); manifest.addPlugin( @@ -1552,21 +2342,21 @@ describe("clean", () => { strict: false, files: {}, scope: "project", + projectHooks: { + entries: [{ event: "PreToolUse", command, digest: md5(JSON.stringify({ command })) }], + scripts: {}, + }, }) ); const fs = new InMemoryFileAdapter(); const hooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); - const scriptMarker = cursorProjectHooksScriptDir(pluginName); const otherScriptMarker = cursorProjectHooksScriptDir(otherPluginName); await fs.writeFile( hooksPath, JSON.stringify({ version: 1, hooks: { - PreToolUse: [ - { command: `./${scriptMarker}run.js` }, - { command: `./${otherScriptMarker}run.js` }, - ], + PreToolUse: [{ command }, { command: `./${otherScriptMarker}run.js` }], }, }) ); @@ -1577,7 +2367,7 @@ describe("clean", () => { ); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -1591,10 +2381,192 @@ describe("clean", () => { expect(remainingHooks).toContain(otherScriptMarker); expect(fs.has(otherScriptPath)).toBe(true); }); + + it("rejects an edited Cursor hook before dropping a shared source reference or touching native state", async () => { + const pluginName = "aidd-context"; + const scriptMarker = cursorProjectHooksScriptDir(pluginName); + const command = `./${scriptMarker}run.js`; + const scriptPath = join(PROJECT_ROOT, scriptMarker, "run.js"); + const installedScript = "// installed hook"; + const editedScript = "// edited by the user"; + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + projectHooks: { + entries: [{ event: "PreToolUse", command, digest: md5(JSON.stringify({ command })) }], + scripts: { [`${scriptMarker}run.js`]: md5(installedScript) }, + }, + }) + ); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + { + alias: "project-native", + hostName: "other-host", + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], + pluginRefs: ["aidd-context@aidd-framework"], + }); + const fs = new InMemoryFileAdapter(); + const hooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); + await fs.writeFile( + hooksPath, + JSON.stringify({ version: 1, hooks: { PreToolUse: [{ command }] } }) + ); + await fs.writeFile(scriptPath, editedScript); + const cachePath = join(PROJECT_ROOT, ".aidd", "cache", "built.json"); + const gitignorePath = join(PROJECT_ROOT, ".gitignore"); + fs.setFile(cachePath, "built source"); + fs.setFile(gitignorePath, ".aidd/cache/\n"); + fs.setFile(join(PROJECT_ROOT, "marker"), ""); + fs.setFile("/other-project/marker", ""); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + await references.addReference("1.0.0", "/other-project"); + const registry = new InMemoryMarketplaceRegistry(); + for (const [name, scope] of [ + ["aidd-framework", "user"], + ["project-native", "project"], + ] as const) { + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name, + source: { kind: "local", path: "/some/built/path" }, + scope, + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + } + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([["codex", activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + references, + undefined, + new InMemoryManifestRepository(Manifest.create(), "/fake-home") + ); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "edited after install" + ); + + expect(await references.listAllReferencingProjects()).toContain(PROJECT_ROOT); + expect(await references.listAllReferencingProjects()).toContain("/other-project"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(scriptPath)).toBe(editedScript); + expect(fs.getFile(cachePath)).toBe("built source"); + expect(fs.getFile(gitignorePath)).toBe(".aidd/cache/\n"); + expect(manifestRepo.getCurrent()).toBe(manifest); + expect(fs.has(hooksPath)).toBe(true); + expect(fs.has(join(PROJECT_ROOT, "marker"))).toBe(true); + }); }); - describe("user-scope containment for a plugin's own files", () => { - it("refuses to delete a manifest entry whose relative path escapes the user-scope directory via `..`, while still deleting its legitimate sibling", async () => { + it("rejects an edited OpenCode MCP entry before dropping the shared source reference", async () => { + const originalServer = { type: "local" }; + const editedConfig = JSON.stringify({ mcp: { mine: { type: "remote" } } }); + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { mine: md5(JSON.stringify(originalServer)) }, + scope: "project", + }) + ); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], + pluginRefs: ["aidd-context@aidd-framework"], + }); + const fs = new InMemoryFileAdapter(); + const configPath = join(PROJECT_ROOT, "opencode.json"); + await fs.writeFile(configPath, editedConfig); + fs.setFile(join(PROJECT_ROOT, "marker"), ""); + fs.setFile("/other-project/marker", ""); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + await references.addReference("1.0.0", "/other-project"); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([["codex", activator]]), + undefined, + undefined, + new Map(), + () => "/fake-home", + references + ); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "edited" + ); + + expect(await references.listAllReferencingProjects()).toContain(PROJECT_ROOT); + expect(await references.listAllReferencingProjects()).toContain("/other-project"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(configPath)).toBe(editedConfig); + }); + + describe("user-scope files belong to machine cleanup", () => { + it("leaves both legitimate and escaping user paths untouched", async () => { const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); manifest.addPlugin( @@ -1614,15 +2586,20 @@ describe("clean", () => { const fs = new RecordingFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const logger = new CapturingLogger(); - const useCase = new CleanUseCase(fs, manifestRepo, logger, new GitignoreUseCase(fs)); + const useCase = new CleanWithKnownHostSource( + fs, + manifestRepo, + logger, + new GitignoreUseCase(fs) + ); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect( fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", PLUGIN_KEY))) - ).toBe(true); + ).toBe(false); expect(fs.deletedPaths.some((p) => p.includes(join(".ssh", "id_rsa")))).toBe(false); - expect(logger.warnMessages.some((m) => m.includes("id_rsa"))).toBe(true); + expect(logger.warnMessages.some((m) => m.includes("id_rsa"))).toBe(false); }); it("refuses to delete a plugin whose own directory is a symlink resolving outside the user-scope directory", async () => { @@ -1644,7 +2621,12 @@ describe("clean", () => { fs.setSymlink(join(boundary, "aidd-context"), "/tmp/evil-aidd-context"); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const logger = new CapturingLogger(); - const useCase = new CleanUseCase(fs, manifestRepo, logger, new GitignoreUseCase(fs)); + const useCase = new CleanWithKnownHostSource( + fs, + manifestRepo, + logger, + new GitignoreUseCase(fs) + ); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); @@ -1652,14 +2634,14 @@ describe("clean", () => { expect( fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", PLUGIN_KEY))) ).toBe(false); - expect(logger.warnMessages.some((m) => m.includes(PLUGIN_KEY))).toBe(true); + expect(logger.warnMessages.some((m) => m.includes(PLUGIN_KEY))).toBe(false); }); }); describe("without a manifest", () => { it("reports nothing found and nothing to preview", async () => { const fs = new InMemoryFileAdapter(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(null, PROJECT_ROOT), new CapturingLogger(), @@ -1682,7 +2664,7 @@ describe("clean", () => { fs: InMemoryFileAdapter, prompter: RecordingPrompter | undefined ) { - return new CleanUseCase( + return new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(seedTrackedFiles(fs, ["a.md"]), PROJECT_ROOT), new CapturingLogger(), @@ -1775,7 +2757,7 @@ describe("clean", () => { [{ relativePath: ".claude/settings.json", sectionKey: null, entries: { owned: hash() } }] ); manifest.addTool("codex", "1.0.0", [fileEntry("c.md")]); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(manifest, PROJECT_ROOT), new CapturingLogger(), @@ -1804,7 +2786,7 @@ describe("clean", () => { pluginRefs: ["aidd-context@aidd-framework"], }); const fs = new InMemoryFileAdapter(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(manifest, PROJECT_ROOT), new CapturingLogger(), @@ -1833,7 +2815,7 @@ describe("clean", () => { pluginRefs: [], }); const fs = new InMemoryFileAdapter(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(manifest, PROJECT_ROOT), new CapturingLogger(), @@ -1860,7 +2842,7 @@ describe("clean", () => { manifest: Manifest, logger = new CapturingLogger() ) { - return new CleanUseCase( + return new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(manifest, PROJECT_ROOT), logger, @@ -1902,25 +2884,31 @@ describe("clean", () => { it("counts the project hooks file a cursor plugin was unmerged from", async () => { const pluginName = "aidd-context"; + const scriptMarker = cursorProjectHooksScriptDir(pluginName); + const command = `./${scriptMarker}run.js`; const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); manifest.addPlugin( "cursor", - InstalledPlugin.fromMetadata( - pluginName, - "1.0.0", - { kind: "local", path: "/p" }, - false, - "project" - ) + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/p" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + projectHooks: { + entries: [{ event: "PreToolUse", command, digest: md5(JSON.stringify({ command })) }], + scripts: {}, + }, + }) ); const fs = new InMemoryFileAdapter(); - const scriptMarker = cursorProjectHooksScriptDir(pluginName); fs.setFile( join(PROJECT_ROOT, ".cursor", "hooks.json"), JSON.stringify({ version: 1, - hooks: { PreToolUse: [{ command: `./${scriptMarker}run.js` }] }, + hooks: { PreToolUse: [{ command }] }, }) ); const useCase = buildCountingUseCase(fs, manifest); @@ -2023,7 +3011,7 @@ describe("clean", () => { const fs = new InMemoryFileAdapter(); fs.setFile(join(PROJECT_ROOT, ".aidd", "config.json"), "{}"); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(seedTrackedFiles(fs, ["a.md"]), PROJECT_ROOT), logger, @@ -2042,7 +3030,7 @@ describe("clean", () => { const fs = new InMemoryFileAdapter(); fs.setFile(join(PROJECT_ROOT, ".aidd", "auth.json"), "{}"); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(seedTrackedFiles(fs, ["a.md"]), PROJECT_ROOT), logger, diff --git a/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts b/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts index e1fd62d06..e2d52e326 100644 --- a/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts +++ b/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts @@ -2,6 +2,7 @@ * copilot), a ref is left enabled while another project still references its shared source. */ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { describe, expect, it } from "vitest"; import { FRAMEWORK_MARKETPLACE_NAME, @@ -11,9 +12,12 @@ import { CleanUseCase } from "../../../../../src/contexts/framework/application/ import { GitignoreUseCase } from "../../../../../src/contexts/framework/application/gitignore-use-case.js"; import type { NativeMarketplaceRegistration } from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { UserSourceReferencesAdapter } from "../../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import type { NativeMarketplaceSourceReader } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -22,6 +26,54 @@ import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory const PROJECT_ROOT = "/test-project"; const OTHER_PROJECT = "/other-project"; const USER_CONFIG_DIR = "/fake-home/.config/aidd"; +const OTHER_PROJECT_BYTES = "B still uses these bytes"; + +function provenRegistration( + toolId: "codex" | "claude", + alias: string, + hostName: string +): NativeMarketplaceRegistration { + const source = hostName === "other-mkt" ? "/other/built/path" : "/some/built/path"; + return { + alias, + hostName, + provenance: + toolId === "claude" + ? { kind: "registry", source } + : { kind: "effective-list", root: source, sourceType: "local", source }, + }; +} + +/** A separate, literal host snapshot: not assembled from the machine claim. */ +function currentHostSource(toolId: "codex" | "claude"): NativeMarketplaceSourceReader { + return { + read: async () => ({ + location: "fixture native host", + entries: new Map([ + [ + "aidd-framework", + toolId === "claude" + ? { kind: "registry" as const, source: "/some/built/path" } + : { + kind: "effective-list" as const, + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + ], + [ + "other-mkt", + { + kind: "effective-list" as const, + root: "/other/built/path", + sourceType: "local", + source: "/other/built/path", + }, + ], + ]), + }), + }; +} function seedManifest( toolId: "codex" | "claude", @@ -38,7 +90,9 @@ function seedManifest( return manifest; } -function seedSharedMarketplaceRegistry(): InMemoryMarketplaceRegistry { +function seedSharedMarketplaceRegistry( + withOtherProjectCatalogue = false +): InMemoryMarketplaceRegistry { const registry = new InMemoryMarketplaceRegistry(); registry.save( PROJECT_ROOT, @@ -49,6 +103,17 @@ function seedSharedMarketplaceRegistry(): InMemoryMarketplaceRegistry { addedAt: "2026-01-01T00:00:00.000Z", }) ); + if (withOtherProjectCatalogue) { + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "other-mkt", + source: { kind: "local", path: "/other/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + } return registry; } @@ -60,16 +125,18 @@ function seedReferences(fs: InMemoryFileAdapter, roots: readonly string[]): void // `listAllReferencingProjects` filters by `fs.fileExists(root)`, so every root seeded here // needs a marker of its own or it reads back as no project at all. fs.setFile(`${PROJECT_ROOT}/marker`, ""); - for (const root of roots) fs.setFile(`${root}/marker`, ""); + for (const root of roots) fs.setFile(`${root}/marker`, OTHER_PROJECT_BYTES); } function buildUseCase(deps: { fs: InMemoryFileAdapter; manifest: Manifest; activator: FakeNativePluginActivator; - binary: string; + binary: "codex" | "claude"; logger: CapturingLogger; aiddMarketplaceRegistry: InMemoryMarketplaceRegistry; + userManifestRepo?: InMemoryManifestRepository; + gitignore?: GitignoreUseCase; }): CleanUseCase { const manifestRepo = new InMemoryManifestRepository(deps.manifest, PROJECT_ROOT); const userSourceReferences = new UserSourceReferencesAdapter(deps.fs, () => USER_CONFIG_DIR); @@ -77,18 +144,129 @@ function buildUseCase(deps: { deps.fs, manifestRepo, deps.logger, - new GitignoreUseCase(deps.fs), + deps.gitignore ?? new GitignoreUseCase(deps.fs), new Map([[deps.binary, deps.activator]]), deps.aiddMarketplaceRegistry, undefined, new Map(), - undefined, + () => "/fake-home", userSourceReferences, - new Map() + new Map([ + [ + deps.binary, + new FakeHostPluginRegistryReader({ + location: "fixture host installed-plugin registry", + refs: new Map([ + ["aidd-vcs@aidd-framework", { enabled: true }], + ["plugin-b@other-mkt", { enabled: true }], + ]), + }), + ], + ]), + deps.userManifestRepo, + new Map([[deps.binary, currentHostSource(deps.binary)]]) ); } describe("clean guards a ref another project on this machine still needs", () => { + it.each(["succeeds", "fails"] as const)( + "releases A's native and user-plugin claims only when local cleanup %s", + async (outcome) => { + const fs = new InMemoryFileAdapter(); + seedReferences(fs, [OTHER_PROJECT]); + const ref = "aidd-vcs@aidd-framework"; + const otherRef = "plugin-b@other-mkt"; + const registration = provenRegistration("codex", "aidd-framework", "aidd-framework"); + const project = seedManifest("codex", [registration], [ref]); + project.addTool("cursor", "1.0.0", []); + const record = (name: string, scope: "project" | "user" = "user") => + InstalledPlugin.fromJSON({ + name, + source: { kind: "local", path: "/shared/plugins" }, + version: "1.0.0", + strict: false, + scope, + files: {}, + dependents: [PROJECT_ROOT, OTHER_PROJECT], + }); + project.addPlugin("cursor", record("shared").withDependents([])); + project.addPlugin("cursor", record("local-only", "project").withDependents([])); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [registration, provenRegistration("codex", "other-mkt", "other-mkt")], + pluginRefs: [ref, otherRef], + pluginClaims: [ref, otherRef].map((ref) => ({ + ref, + dependents: [PROJECT_ROOT, OTHER_PROJECT], + })), + }); + machine.addTool("cursor", "1.0.0", []); + for (const name of ["shared", "local-only", "unrelated"]) + machine.addPlugin("cursor", record(name)); + const machineRepo = new InMemoryManifestRepository(machine); + const gitignore = new GitignoreUseCase(fs); + if (outcome === "fails") + gitignore.remove = async () => { + throw new Error("gitignore cleanup failed"); + }; + fs.setFile(`${OTHER_PROJECT}/plugin-content.md`, OTHER_PROJECT_BYTES); + const sharedPath = "/fake-home/.cursor/plugins/local/shared/skills/demo.md"; + fs.setFile(sharedPath, "shared plugin bytes"); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs, + manifest: project, + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + userManifestRepo: machineRepo, + gitignore, + }); + + if (outcome === "fails") + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "gitignore cleanup failed" + ); + else await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + const dependents = outcome === "succeeds" ? [OTHER_PROJECT] : [PROJECT_ROOT, OTHER_PROJECT]; + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref, dependents }, + { ref: otherRef, dependents: [PROJECT_ROOT, OTHER_PROJECT] }, + ]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + ref, + otherRef, + ]); + expect( + machineRepo + .getCurrent() + ?.getPlugins("cursor") + .map((plugin) => ({ + name: plugin.name, + dependents: plugin.dependents, + })) + ).toEqual([ + { name: "shared", dependents }, + { name: "local-only", dependents: [PROJECT_ROOT, OTHER_PROJECT] }, + { name: "unrelated", dependents: [PROJECT_ROOT, OTHER_PROJECT] }, + ]); + expect( + await new UserSourceReferencesAdapter( + fs, + () => USER_CONFIG_DIR + ).listAllReferencingProjects() + ).toEqual(dependents); + expect(fs.getFile(`${OTHER_PROJECT}/plugin-content.md`)).toBe(OTHER_PROJECT_BYTES); + expect(fs.getFile(sharedPath)).toBe("shared plugin bytes"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + } + ); + it("keeps codex's ref enabled and names the other project still referencing the shared source", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); @@ -99,7 +277,7 @@ describe("clean guards a ref another project on this machine still needs", () => fs, manifest: seedManifest( "codex", - [{ alias: "aidd-framework", hostName: "aidd-framework" }], + [provenRegistration("codex", "aidd-framework", "aidd-framework")], ["aidd-vcs@aidd-framework"] ), activator, @@ -111,12 +289,14 @@ describe("clean guards a ref another project on this machine still needs", () => await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect(activator.uninstalledPlugins).not.toContain("aidd-vcs@aidd-framework"); + expect(fs.getFile(`${USER_CONFIG_DIR}/references.json`)).toContain(OTHER_PROJECT); + expect(fs.getFile(`${OTHER_PROJECT}/marker`)).toBe(OTHER_PROJECT_BYTES); expect( logger.warnMessages.some((m) => m.includes("left enabled") && m.includes(OTHER_PROJECT)) ).toBe(true); }); - it("disables codex's ref once this project holds the last reference to the shared source", async () => { + it("leaves an unclaimed machine-global Codex ref enabled after the last local reference", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, []); const activator = new FakeNativePluginActivator({ available: true }); @@ -125,7 +305,7 @@ describe("clean guards a ref another project on this machine still needs", () => fs, manifest: seedManifest( "codex", - [{ alias: "aidd-framework", hostName: "aidd-framework" }], + [provenRegistration("codex", "aidd-framework", "aidd-framework")], ["aidd-vcs@aidd-framework"] ), activator, @@ -136,7 +316,11 @@ describe("clean guards a ref another project on this machine still needs", () => await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).toContain("aidd-vcs@aidd-framework"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect( + await new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR).listAllReferencingProjects() + ).not.toContain(PROJECT_ROOT); }); it("still disables claude's ref even with another project referencing the shared source", async () => { @@ -148,7 +332,7 @@ describe("clean guards a ref another project on this machine still needs", () => fs, manifest: seedManifest( "claude", - [{ alias: "aidd-framework", hostName: "aidd-framework" }], + [provenRegistration("claude", "aidd-framework", "aidd-framework")], ["aidd-vcs@aidd-framework"] ), activator, @@ -160,9 +344,10 @@ describe("clean guards a ref another project on this machine still needs", () => await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect(activator.uninstalledPlugins).toContain("aidd-vcs@aidd-framework"); + expect(fs.getFile(`${OTHER_PROJECT}/marker`)).toBe(OTHER_PROJECT_BYTES); }); - it("still disables a ref from a marketplace that is not the shared source, in the same run", async () => { + it("leaves both unclaimed Codex refs enabled despite a distinct project catalogue", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); @@ -172,24 +357,26 @@ describe("clean guards a ref another project on this machine still needs", () => manifest: seedManifest( "codex", [ - { alias: "aidd-framework", hostName: "aidd-framework" }, - { alias: "other-mkt", hostName: "other-mkt" }, + provenRegistration("codex", "aidd-framework", "aidd-framework"), + provenRegistration("codex", "other-mkt", "other-mkt"), ], ["aidd-vcs@aidd-framework", "plugin-b@other-mkt"] ), activator, binary: "codex", logger: new CapturingLogger(), - aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(true), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).not.toContain("aidd-vcs@aidd-framework"); - expect(activator.uninstalledPlugins).toContain("plugin-b@other-mkt"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(`${USER_CONFIG_DIR}/references.json`)).toContain(OTHER_PROJECT); + expect(fs.getFile(`${OTHER_PROJECT}/marker`)).toBe(OTHER_PROJECT_BYTES); }); - it("still disables a ref from another marketplace when that marketplace is recorded before the shared source", async () => { + it("leaves both unclaimed Codex refs enabled when the project catalogue is listed first", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); @@ -199,23 +386,26 @@ describe("clean guards a ref another project on this machine still needs", () => manifest: seedManifest( "codex", [ - { alias: "other-mkt", hostName: "other-mkt" }, - { alias: "aidd-framework", hostName: "aidd-framework" }, + provenRegistration("codex", "other-mkt", "other-mkt"), + provenRegistration("codex", "aidd-framework", "aidd-framework"), ], ["plugin-b@other-mkt", "aidd-vcs@aidd-framework"] ), activator, binary: "codex", logger: new CapturingLogger(), - aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(true), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).toStrictEqual(["plugin-b@other-mkt"]); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(`${USER_CONFIG_DIR}/references.json`)).toContain(OTHER_PROJECT); + expect(fs.getFile(`${OTHER_PROJECT}/marker`)).toBe(OTHER_PROJECT_BYTES); }); - it("disables codex's ref when no claim was ever recorded for this project, and no other project references it either", async () => { + it("leaves an unclaimed global Codex ref enabled even without a recorded project reference", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); // No references.json at all: no claim of this project's own to drop, and nothing else // referencing the source to guard on either. @@ -225,7 +415,7 @@ describe("clean guards a ref another project on this machine still needs", () => fs, manifest: seedManifest( "codex", - [{ alias: "aidd-framework", hostName: "aidd-framework" }], + [provenRegistration("codex", "aidd-framework", "aidd-framework")], ["aidd-vcs@aidd-framework"] ), activator, @@ -236,7 +426,8 @@ describe("clean guards a ref another project on this machine still needs", () => await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).toContain("aidd-vcs@aidd-framework"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); }); // "This project's own claim was never recorded" and "no other project references it" are @@ -244,7 +435,7 @@ describe("clean guards a ref another project on this machine still needs", () => it("keeps codex's ref enabled when this project's own claim was never recorded but another project's still is", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); fs.setFile(`${USER_CONFIG_DIR}/references.json`, JSON.stringify({ "1.0.0": [OTHER_PROJECT] })); - fs.setFile(`${OTHER_PROJECT}/marker`, ""); + fs.setFile(`${OTHER_PROJECT}/marker`, OTHER_PROJECT_BYTES); const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); @@ -252,7 +443,7 @@ describe("clean guards a ref another project on this machine still needs", () => fs, manifest: seedManifest( "codex", - [{ alias: "aidd-framework", hostName: "aidd-framework" }], + [provenRegistration("codex", "aidd-framework", "aidd-framework")], ["aidd-vcs@aidd-framework"] ), activator, @@ -264,6 +455,8 @@ describe("clean guards a ref another project on this machine still needs", () => await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect(activator.uninstalledPlugins).not.toContain("aidd-vcs@aidd-framework"); + expect(fs.getFile(`${USER_CONFIG_DIR}/references.json`)).toContain(OTHER_PROJECT); + expect(fs.getFile(`${OTHER_PROJECT}/marker`)).toBe(OTHER_PROJECT_BYTES); expect( logger.warnMessages.some((m) => m.includes("left enabled") && m.includes(OTHER_PROJECT)) ).toBe(true); diff --git a/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts b/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts index 78c203f75..100c0232c 100644 --- a/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; @@ -12,11 +14,13 @@ import { CleanUserScopeUseCase } from "../../../../../src/contexts/framework/app import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { UserSourceReferencesAdapter } from "../../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import type { NativeMarketplaceSourceReader } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; import type { MarketplaceScope } from "../../../../../src/kernel/scope.js"; import type { ToolId } from "../../../../../src/kernel/tool.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -114,12 +118,55 @@ function manifestWithClaude(): Manifest { manifest.addTool("claude", "1.0.0", []); manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], pluginRefs: ["aidd-context@aidd-framework"], + pluginClaims: [{ ref: "aidd-context@aidd-framework", dependents: [] }], }); return manifest; } +function provenClaudeSources( + source = "/built/claude" +): ReadonlyMap<"claude", NativeMarketplaceSourceReader> { + return new Map([ + [ + "claude", + { + read: async () => ({ + location: "known_marketplaces.json", + entries: new Map([["aidd-framework", { kind: "registry" as const, source }]]), + }), + }, + ], + ]); +} + +function provenHostPlugins( + manifest: Manifest +): ReadonlyMap<"claude" | "codex", FakeHostPluginRegistryReader> { + const readers = new Map<"claude" | "codex", FakeHostPluginRegistryReader>(); + for (const toolId of ["claude", "codex"] as const) { + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) continue; + readers.set( + toolId, + new FakeHostPluginRegistryReader({ + location: `${toolId} installed plugins`, + refs: new Map( + (registrations.pluginClaims ?? []).map(({ ref }) => [ref, { enabled: true }]) + ), + }) + ); + } + return readers; +} + /** Seeds the cache path claude's own profile declares with a marker file: an empty or absent * directory is silently skipped rather than purged. */ function seedClaudeCache(fs: InMemoryFileAdapter, hostName = "aidd-framework"): string { @@ -129,6 +176,344 @@ function seedClaudeCache(fs: InMemoryFileAdapter, hostName = "aidd-framework"): } describe("clean --scope user", () => { + it("reads canonical dependents after acquiring the machine lock", async () => { + const machine = manifestWithClaude(); + class LockedRepository extends InMemoryManifestRepository { + async withExclusiveAccess(action: () => Promise): Promise { + // A concurrent install completed while clean was waiting for the lock. + const registration = machine.getNativeRegistrations("claude"); + if (registration === undefined) throw new Error("fixture missing native registration"); + machine.setNativeRegistrations("claude", { + ...registration, + pluginClaims: [{ ref: "aidd-context@aidd-framework", dependents: ["/project-b"] }], + }); + return action(); + } + } + const repo = new LockedRepository(machine); + const fs = new RecordingFileAdapter(); + const cacheMarker = join(seedClaudeCache(fs), "marker.json"); + const sourceMarker = join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "catalogue.json"); + fs.setFile(sourceMarker, "source"); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["claude", activator]]), + new Map(), + () => HOME, + undefined, + undefined, + provenClaudeSources(), + provenHostPlugins(machine) + ); + + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /user-scope plugin files.*active projects.*\/project-b/ + ); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(sourceMarker)).toBe("source"); + expect(fs.getFile(cacheMarker)).toBe("{}"); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual([ + { ref: "aidd-context@aidd-framework", dependents: ["/project-b"] }, + ]); + }); + + it("refuses an owned catalogue with a foreign enabled plugin ref before any global clean effect", async () => { + const machine = manifestWithClaude(); + const repo = new InMemoryManifestRepository(machine); + const fs = new RecordingFileAdapter(); + const foreignCache = join(CLAUDE_CACHE, "aidd-framework", "foreign-payload", "bytes"); + fs.setFile(foreignCache, "foreign bytes"); + const activator = new RecordingActivator([]); + const hostPlugins = new FakeHostPluginRegistryReader({ + location: "installed_plugins.json", + refs: new Map([ + ["aidd-context@aidd-framework", { enabled: true }], + ["foreign@aidd-framework", { enabled: true }], + ]), + }); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["claude", activator]]), + new Map(), + () => HOME, + undefined, + undefined, + provenClaudeSources(), + new Map([["claude", hostPlugins]]) + ); + await expect(useCase.execute({ projectRoot: "/A", force: true })).rejects.toThrow( + /foreign.*ref.*foreign@aidd-framework/ + ); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(await fs.readFile(foreignCache)).toBe("foreign bytes"); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toHaveLength(1); + }); + it("preflights every native source before uninstalling any other catalogue", async () => { + const machine = manifestWithClaude(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { + alias: "foreign-alias", + hostName: "foreign-name", + provenance: { kind: "effective-list", root: "/old", sourceType: "local", source: "/old" }, + }, + ], + pluginRefs: ["plugin@foreign-name"], + pluginClaims: [{ ref: "plugin@foreign-name", dependents: [] }], + }); + const claude = new RecordingActivator([]); + const codex = new RecordingActivator([]); + const repo = new InMemoryManifestRepository(machine); + const fs = new RecordingFileAdapter(); + const claudeSource = provenClaudeSources().get("claude"); + if (claudeSource === undefined) throw new Error("fixture missing Claude source reader"); + const sources = new Map<"claude" | "codex", NativeMarketplaceSourceReader>([ + ["claude", claudeSource], + [ + "codex", + { + read: async () => ({ + location: "host", + entries: new Map([ + [ + "foreign-name", + { + kind: "effective-list", + root: "/foreign", + sourceType: "local", + source: "/foreign", + }, + ], + ]), + }), + }, + ], + ]); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([ + ["claude", claude], + ["codex", codex], + ]), + new Map(), + () => HOME, + undefined, + undefined, + sources, + provenHostPlugins(machine) + ); + await expect(useCase.execute({ projectRoot: "/A", force: true })).rejects.toThrow( + /current host source differs.*reconcile manually/ + ); + expect(claude.uninstalledPlugins).toEqual([]); + expect(claude.removedMarketplaces).toEqual([]); + expect(codex.uninstalledPlugins).toEqual([]); + expect(codex.removedMarketplaces).toEqual([]); + expect(repo.getCurrent()?.getNativeRegistrations("claude")).toBeDefined(); + }); + it("refuses all native host mutations when a stale canonical source points at a foreign Codex catalogue", async () => { + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { + alias: "alias", + hostName: "foreign-name", + provenance: { kind: "effective-list", root: "/old", sourceType: "local", source: "/old" }, + }, + ], + pluginRefs: ["plugin@foreign-name"], + pluginClaims: [{ ref: "plugin@foreign-name", dependents: [] }], + }); + const repo = new InMemoryManifestRepository(machine); + const fs = new RecordingFileAdapter(); + const activator = new RecordingActivator([]); + const source: NativeMarketplaceSourceReader = { + read: async () => ({ + location: "host", + entries: new Map([ + [ + "foreign-name", + { kind: "effective-list", root: "/foreign", sourceType: "local", source: "/foreign" }, + ], + ]), + }), + }; + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["codex", activator]]), + new Map(), + () => HOME, + undefined, + undefined, + new Map([["codex", source]]) + ); + await expect(useCase.execute({ projectRoot: "/A", force: true })).rejects.toThrow( + /current host source differs.*reconcile manually/ + ); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(repo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toHaveLength(1); + }); + it("refuses an active B native ref even with force, then succeeds only after B detaches", async () => { + const machine = manifestWithClaude(); + const registrations = machine.getNativeRegistrations("claude"); + if (registrations === undefined) throw new Error("fixture missing native registration"); + machine.setNativeRegistrations("claude", { + ...registrations, + pluginClaims: [{ ref: "aidd-context@aidd-framework", dependents: ["/B"] }], + }); + const repo = new InMemoryManifestRepository(machine); + const fs = new RecordingFileAdapter(); + const activator = new RecordingActivator([]); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ + location: "known_marketplaces.json", + entries: new Map(), + }), + ], + ]), + () => HOME, + undefined, + undefined, + provenClaudeSources(), + provenHostPlugins(machine) + ); + await expect(useCase.execute({ projectRoot: "/A", force: true })).rejects.toThrow( + /active projects.*\/B/ + ); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual([ + { ref: "aidd-context@aidd-framework", dependents: ["/B"] }, + ]); + machine.setNativeRegistrations("claude", { + ...registrations, + pluginClaims: [{ ref: "aidd-context@aidd-framework", dependents: [] }], + }); + await useCase.execute({ projectRoot: "/A", force: true }); + expect(activator.uninstalledPlugins).toEqual(["aidd-context@aidd-framework"]); + expect(activator.removedMarketplaces).toEqual(["aidd-framework"]); + expect(repo.getCurrent()).toBeNull(); + }); + + it("refuses an active B Cursor user plugin but never treats project files as machine-owned", async () => { + const userFile = join(homedir(), ".cursor", "plugins", "local", "shared", "skills", "x.md"); + const projectFile = "/A/skills/local.md"; + const fs = new RecordingFileAdapter(); + fs.setFile(userFile, "shared bytes"); + fs.setFile(projectFile, "project bytes"); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + const shared = InstalledPlugin.fromMetadata( + "shared", + "1.0.0", + { kind: "local", path: "/source" }, + false, + "user" + ) + .withFiles( + new Map([["shared/skills/x.md", createHash("md5").update("shared bytes").digest("hex")]]) + ) + .withDependents(["/B"]); + machine.addPlugin("cursor", shared); + machine.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "local", + "1.0.0", + { kind: "local", path: "/source" }, + false, + "project" + ) + .withFiles(new Map([["skills/local.md", "hash"]])) + .withDependents(["/project-foreign"]) + ); + const repo = new InMemoryManifestRepository(machine); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + homedir + ); + + expect( + (await useCase.execute({ projectRoot: "/A", force: false })).preview.activePluginDependents + ).toEqual(["/B"]); + await expect(useCase.execute({ projectRoot: "/A", force: true })).rejects.toThrow(/\/B/); + expect(fs.has(userFile)).toBe(true); + expect(fs.has(projectFile)).toBe(true); + expect( + repo + .getCurrent() + ?.getPlugins("cursor") + .find((plugin) => plugin.name === "shared")?.dependents + ).toEqual(["/B"]); + + machine.updatePlugin("cursor", shared.withDependents([])); + await useCase.execute({ projectRoot: "/A", force: true }); + expect(repo.getCurrent()).toBeNull(); + expect(fs.has(userFile)).toBe(false); + expect(fs.order).toContain(`deleteFile:${userFile}`); + expect(fs.has(projectFile)).toBe(true); + }); + + it("retains canonical claims if the user catalogue registry refuses the final delete", async () => { + class RefusingRegistry extends InMemoryMarketplaceRegistry { + override async delete(_root: string, _name: string, _scope: MarketplaceScope): Promise { + throw new Error("registry delete failed"); + } + } + const repo = new InMemoryManifestRepository(Manifest.create()); + const useCase = new CleanUserScopeUseCase( + new RecordingFileAdapter(), + repo, + new CapturingLogger(), + new RefusingRegistry(), + () => USER_CONFIG_DIR + ); + await expect(useCase.execute({ projectRoot: "/A", force: true })).rejects.toThrow( + /registry delete failed/ + ); + expect(repo.getCurrent()).not.toBeNull(); + }); + describe("no user manifest, machine state from a project-scope setup", () => { it("still purges the whitelist and touches no host, naming the referencing projects", async () => { const order: string[] = []; @@ -164,11 +549,10 @@ describe("clean --scope user", () => { userSourceReferences ); - const result = await useCase.execute({ projectRoot: "/wherever", force: true }); - - expect(result.manifestFound).toBe(false); - // The whitelist purge runs regardless of the manifest — it reads nothing from it. - expect(fs.order).toContain(`deleteDirectory:${join(USER_CONFIG_DIR, "cache", "built")}`); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /the shared framework source.*active projects.*\/project-a/ + ); + expect(fs.order).not.toContain(`deleteDirectory:${join(USER_CONFIG_DIR, "cache", "built")}`); // No manifest means no `nativeRegistrations` to drive a host's own CLI through — // the activator that would have recorded a real call never sees one. expect(activator.removedMarketplaces).toEqual([]); @@ -209,7 +593,11 @@ describe("clean --scope user", () => { }), ], ]), - () => HOME + () => HOME, + undefined, + undefined, + provenClaudeSources(), + provenHostPlugins(manifestWithClaude()) ); await useCase.execute({ projectRoot: "/wherever", force: true }); @@ -251,7 +639,7 @@ describe("clean --scope user", () => { ); const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); await userSourceReferences.addReference("1.0.0", "/project-a"); - fs.setFile("/project-a/marker", ""); + await userSourceReferences.removeReference("/project-a"); const useCase = new CleanUserScopeUseCase( fs, manifestRepo, @@ -365,7 +753,9 @@ describe("clean --scope user", () => { () => HOME ); - await useCase.execute({ projectRoot: "/wherever", force: true }); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /tracked file.*escaped/ + ); expect(await fs.fileExists(join(escapeTarget, "evil.md"))).toBe(true); expect(logger.warnMessages.some((m) => m.includes("does not resolve inside"))).toBe(true); @@ -388,15 +778,9 @@ describe("clean --scope user", () => { () => HOME ); - await useCase.execute({ projectRoot: "/wherever", force: true }); - - const warning = logger.warnMessages.find( - (m) => m.includes("claude") && m.includes("not on the PATH") + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /claude.*not on the PATH.*1 marketplace.*1 plugin ref/s ); - expect(warning).toBeDefined(); - expect(warning).toContain("1 marketplace(s)"); - expect(warning).toContain("1 plugin ref(s)"); - expect(warning).toContain(claudeCachePath); // The whitelist step still purges userConfigDir()'s own cache/built/, unrelated // to this host's own cache — only claude's own directory must survive untouched. expect(fs.order).not.toContain(`deleteDirectory:${claudeCachePath}`); @@ -454,7 +838,7 @@ describe("clean --scope user", () => { expect(result.preview.referencingProjects).toEqual([]); }); - it("--force skips confirmation and proceeds even when projects still reference it", async () => { + it("--force cannot remove a shared source still referenced by a project", async () => { const fs = new InMemoryFileAdapter(); fs.setFile("/project-a/marker", ""); const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); @@ -472,10 +856,10 @@ describe("clean --scope user", () => { userSourceReferences ); - const result = await useCase.execute({ projectRoot: "/wherever", force: true }); - - expect(result.dryRun).toBe(false); - expect(manifestRepo.getCurrent()).toBeNull(); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /active projects.*\/project-a/ + ); + expect(manifestRepo.getCurrent()).not.toBeNull(); }); }); @@ -612,7 +996,11 @@ describe("clean --scope user", () => { }), ], ]), - () => HOME + () => HOME, + undefined, + undefined, + provenClaudeSources(), + provenHostPlugins(manifestWithClaude()) ); } @@ -626,7 +1014,12 @@ describe("clean --scope user", () => { expect(result).toStrictEqual({ dryRun: false, manifestFound: true, - preview: { toolIds: ["claude"], builtVersions: [], referencingProjects: [] }, + preview: { + toolIds: ["claude"], + activePluginDependents: [], + builtVersions: [], + referencingProjects: [], + }, }); expect(await fs.fileExists(join(claudeCachePath, "marker.json"))).toBe(false); }); @@ -664,7 +1057,12 @@ describe("clean --scope user", () => { expect(result).toStrictEqual({ dryRun: false, manifestFound: false, - preview: { toolIds: [], builtVersions: [], referencingProjects: [] }, + preview: { + toolIds: [], + activePluginDependents: [], + builtVersions: [], + referencingProjects: [], + }, }); expect(logger.infoMessages).toStrictEqual([ "No host registration was undone: nothing was registered at user scope.", @@ -701,7 +1099,7 @@ describe("clean --scope user", () => { ]); }); - it("reports no referencing project, and warns, when references.json cannot be read", async () => { + it("refuses to interpret an unreadable references.json as no dependent project", async () => { const fs = new InMemoryFileAdapter(); fs.setFile(join(USER_CONFIG_DIR, "references.json"), "not json"); const logger = new CapturingLogger(); @@ -717,10 +1115,10 @@ describe("clean --scope user", () => { new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR) ); - const result = await useCase.execute({ projectRoot: "/wherever", force: false }); - - expect(result.preview.referencingProjects).toStrictEqual([]); - expect(logger.warnMessages.some((m) => m.includes("references.json"))).toBe(true); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /Cannot read.*references.json/ + ); + expect(await fs.fileExists(join(USER_CONFIG_DIR, "references.json"))).toBe(true); }); }); @@ -773,18 +1171,25 @@ describe("clean --scope user", () => { fs: InMemoryFileAdapter; logger: CapturingLogger; manifest?: Manifest; + repo?: InMemoryManifestRepository; binary?: string; + nativeSources?: ReadonlyMap<"claude" | "codex", NativeMarketplaceSourceReader>; + hostPlugins?: ReadonlyMap<"claude" | "codex", FakeHostPluginRegistryReader>; activator: NativePluginActivator; }) { return new CleanUserScopeUseCase( deps.fs, - new InMemoryManifestRepository(deps.manifest ?? manifestWithClaude()), + deps.repo ?? new InMemoryManifestRepository(deps.manifest ?? manifestWithClaude()), deps.logger, new InMemoryMarketplaceRegistry(), () => USER_CONFIG_DIR, new Map([[deps.binary ?? "claude", deps.activator]]), new Map(), - () => HOME + () => HOME, + undefined, + undefined, + deps.nativeSources ?? provenClaudeSources(), + deps.hostPlugins ?? provenHostPlugins(deps.manifest ?? manifestWithClaude()) ); } @@ -802,7 +1207,294 @@ describe("clean --scope user", () => { expect(activator.uninstalledPluginScopes).toStrictEqual(["user"]); }); - it("names a refused plugin uninstall by the host's own words and still unregisters the marketplace", async () => { + it("cleans an empty native registration without inventing any plugin uninstall", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [], + pluginRefs: [], + }); + const repo = new InMemoryManifestRepository(manifest); + const fs = new InMemoryFileAdapter(); + const sourceMarker = join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "catalogue.json"); + fs.setFile(sourceMarker, "source"); + const activator = new FakeNativePluginActivator({ available: true }); + + const result = await buildUseCase({ + fs, + manifest, + repo, + activator, + logger: new CapturingLogger(), + nativeSources: new Map(), + hostPlugins: new Map(), + }).execute({ projectRoot: "/wherever", force: true }); + + expect(result).toEqual({ + dryRun: false, + manifestFound: true, + preview: { + toolIds: ["claude"], + activePluginDependents: [], + builtVersions: ["1.0.0"], + referencingProjects: [], + }, + }); + expect(repo.getCurrent()).toBeNull(); + expect(fs.has(sourceMarker)).toBe(false); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + }); + + it("retains canonical state when the recorded host catalogue is absent", async () => { + const manifest = manifestWithClaude(); + const repo = new InMemoryManifestRepository(manifest); + const fs = new RecordingFileAdapter(); + const marker = join(seedClaudeCache(fs), "marker.json"); + const activator = new FakeNativePluginActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => ({ location: "Claude source registry", entries: new Map() }), + }; + + await expect( + buildUseCase({ + fs, + manifest, + repo, + activator, + logger: new CapturingLogger(), + nativeSources: new Map([["claude", source]]), + }).execute({ projectRoot: "/wherever", force: true }) + ).rejects.toThrow("claude: catalogue source unproven."); + + expect(fs.order).toEqual([]); + expect(fs.getFile(marker)).toBe("{}"); + expect(repo.getCurrent()?.toJSON()).toEqual(manifest.toJSON()); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + }); + + it("retains canonical state if the binary disappears after source preflight", async () => { + let available = true; + class DisappearingActivator extends FakeNativePluginActivator { + override isAvailable(): boolean { + return available; + } + } + const manifest = manifestWithClaude(); + const repo = new InMemoryManifestRepository(manifest); + const fs = new RecordingFileAdapter(); + const marker = join(seedClaudeCache(fs), "marker.json"); + const activator = new DisappearingActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => { + available = false; + return { + location: "Claude source registry", + entries: new Map([ + ["aidd-framework", { kind: "registry" as const, source: "/built/claude" }], + ]), + }; + }, + }; + + await expect( + buildUseCase({ + fs, + manifest, + repo, + activator, + logger: new CapturingLogger(), + nativeSources: new Map([["claude", source]]), + }).execute({ projectRoot: "/wherever", force: true }) + ).rejects.toThrow( + `claude: registration left in place, the claude CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 1 plugin ref(s). Its cache survives at: ${join(CLAUDE_CACHE, "aidd-framework")}. User clean refused; canonical claims retained.` + ); + + expect(fs.order).toEqual([]); + expect(fs.getFile(marker)).toBe("{}"); + expect(repo.getCurrent()?.toJSON()).toEqual(manifest.toJSON()); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + }); + + it.each(["missing catalogue", "duplicate claim", "disabled ref", "missing ref"] as const)( + "refuses %s before native or cache cleanup and retains the canonical manifest", + async (fault) => { + const manifest = manifestWithClaude(); + const registration = manifest.getNativeRegistrations("claude"); + if (registration === undefined) throw new Error("fixture missing registration"); + if (fault === "missing catalogue") + manifest.setNativeRegistrations("claude", { ...registration, marketplaces: [] }); + if (fault === "duplicate claim") + manifest.setNativeRegistrations("claude", { + ...registration, + pluginClaims: [ + ...(registration.pluginClaims ?? []), + { + ref: "aidd-context@aidd-framework", + dependents: [], + }, + ], + }); + const before = manifest.toJSON(); + const repo = new InMemoryManifestRepository(manifest); + const fs = new InMemoryFileAdapter(); + const cacheMarker = join(seedClaudeCache(fs), "marker.json"); + const cacheBytes = fs.getFile(cacheMarker); + const activator = new FakeNativePluginActivator({ available: true }); + const hostPlugins = new Map([ + [ + "claude" as const, + new FakeHostPluginRegistryReader({ + location: "literal host plugin registry", + refs: new Map( + fault === "missing ref" + ? [] + : [["aidd-context@aidd-framework", { enabled: fault !== "disabled ref" }]] + ), + }), + ], + ]); + + await expect( + buildUseCase({ + fs, + manifest, + repo, + activator, + hostPlugins, + logger: new CapturingLogger(), + }).execute({ projectRoot: "/wherever", force: true }) + ).rejects.toThrow(/canonical catalogue source|duplicate canonical native ref|not enabled/); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(cacheMarker)).toBe(cacheBytes); + expect(repo.getCurrent()?.toJSON()).toEqual(before); + expect(repo.saveCount).toBe(0); + } + ); + + it("refuses a second missing native binary before uninstalling the first tool", async () => { + const manifest = manifestWithClaude(); + manifest.addTool("copilot", "1.0.0", []); + manifest.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [ + { + alias: "copilot-catalog", + hostName: "copilot-catalog", + provenance: { + kind: "effective-list", + root: "/built/copilot", + sourceType: "local", + source: "/built/copilot", + }, + }, + ], + pluginRefs: [], + }); + const repo = new InMemoryManifestRepository(manifest); + const fs = new InMemoryFileAdapter(); + const cacheMarker = join(seedClaudeCache(fs), "marker.json"); + const before = await fs.readFile(cacheMarker); + const claude = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs, + logger: new CapturingLogger(), + manifest, + repo, + activator: claude, + }); + + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /copilot CLI is not on the PATH.*claims retained/ + ); + expect(claude.uninstalledPlugins).toEqual([]); + expect(claude.removedMarketplaces).toEqual([]); + expect(await fs.readFile(cacheMarker)).toBe(before); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual( + manifest.getNativeRegistrations("claude")?.pluginClaims + ); + }); + + it.each(["claim", "projection"] as const)( + "refuses an orphan machine %s before uninstalling a proven catalogue's own ref", + async (placement) => { + const manifest = manifestWithClaude(); + const registrations = manifest.getNativeRegistrations("claude"); + if (registrations === undefined) throw new Error("fixture missing native registration"); + manifest.setNativeRegistrations("claude", { + ...registrations, + pluginRefs: [...registrations.pluginRefs, "x@B"], + pluginClaims: + placement === "claim" + ? [...(registrations.pluginClaims ?? []), { ref: "x@B", dependents: [] }] + : registrations.pluginClaims, + }); + const repo = new InMemoryManifestRepository(manifest); + const fs = new InMemoryFileAdapter(); + const cacheMarker = join(seedClaudeCache(fs), "marker.json"); + const before = await fs.readFile(cacheMarker); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs, + logger: new CapturingLogger(), + manifest, + repo, + activator, + }); + + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /x@B.*canonical catalogue/ + ); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(await fs.readFile(cacheMarker)).toBe(before); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual( + manifest.getNativeRegistrations("claude")?.pluginClaims + ); + } + ); + + it("refuses duplicate host catalogue registrations before uninstall or cache purge", async () => { + const manifest = manifestWithClaude(); + const registrations = manifest.getNativeRegistrations("claude"); + if (registrations === undefined) throw new Error("fixture missing native registration"); + manifest.setNativeRegistrations("claude", { + ...registrations, + marketplaces: [ + ...registrations.marketplaces, + { ...registrations.marketplaces[0], alias: "second-alias" }, + ], + }); + const repo = new InMemoryManifestRepository(manifest); + const fs = new InMemoryFileAdapter(); + const cacheMarker = join(seedClaudeCache(fs), "marker.json"); + const before = await fs.readFile(cacheMarker); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs, + logger: new CapturingLogger(), + manifest, + repo, + activator, + }); + + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /ambiguous.*aidd-framework/ + ); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(await fs.readFile(cacheMarker)).toBe(before); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.marketplaces).toEqual( + manifest.getNativeRegistrations("claude")?.marketplaces + ); + }); + + it("aborts before marketplace removal when the host refuses plugin uninstall", async () => { const activator = new FakeNativePluginActivator({ available: true, failOnUninstall: ["aidd-context@aidd-framework"], @@ -810,12 +1502,14 @@ describe("clean --scope user", () => { const logger = new CapturingLogger(); const useCase = buildUseCase({ fs: new InMemoryFileAdapter(), logger, activator }); - await useCase.execute({ projectRoot: "/wherever", force: true }); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /plugin uninstall.*failed.*claims retained/ + ); expect(logger.warnMessages).toStrictEqual([ "claude plugin uninstall 'aidd-context@aidd-framework' failed: plugin `aidd-context@aidd-framework` is not installed", ]); - expect(activator.removedMarketplaces).toStrictEqual(["aidd-framework"]); + expect(activator.removedMarketplaces).toStrictEqual([]); }); it("unregisters every marketplace at the user scope", async () => { @@ -840,11 +1534,12 @@ describe("clean --scope user", () => { activator: new FakeNativePluginActivator({ available: true, throwOnRemove: true }), }); - await useCase.execute({ projectRoot: "/wherever", force: true }); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /marketplace remove.*failed.*claims retained/ + ); expect(logger.warnMessages).toStrictEqual([ "claude marketplace remove 'aidd-framework' failed: marketplace remove aidd-framework failed: 'aidd-framework' is not configured or installed", - `claude: cache for 'aidd-framework' left in place, its own removal was not confirmed: ${join(CLAUDE_CACHE, "aidd-framework")}`, ]); }); @@ -853,7 +1548,18 @@ describe("clean --scope user", () => { manifest.addTool("codex", "1.0.0", []); manifest.setNativeRegistrations("codex", { binary: "codex", - marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { + kind: "effective-list", + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }, + }, + ], pluginRefs: [], }); const fs = new InMemoryFileAdapter(); @@ -865,15 +1571,37 @@ describe("clean --scope user", () => { logger, manifest, binary: "codex", + nativeSources: new Map([ + [ + "codex", + { + read: async () => ({ + location: "host", + entries: new Map([ + [ + "aidd-framework", + { + kind: "effective-list" as const, + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }, + ], + ]), + }), + }, + ], + ]), activator: new FakeNativePluginActivator({ available: true, throwOnRemove: true }), }); - await useCase.execute({ projectRoot: "/wherever", force: true }); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /marketplace remove.*failed.*claims retained/ + ); expect(fs.has(leftover)).toBe(true); expect(logger.warnMessages).toStrictEqual([ "codex marketplace remove 'aidd-framework' failed: marketplace remove aidd-framework failed: 'aidd-framework' is not configured or installed", - `codex: cache for 'aidd-framework' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, "aidd-framework")}`, ]); }); }); @@ -890,11 +1618,12 @@ describe("clean --scope user", () => { binary: toolId, marketplaces: [...marketplaces], pluginRefs: [...pluginRefs], + pluginClaims: pluginRefs.map((ref) => ({ ref, dependents: [] })), }); return manifest; } - async function warningsFor(manifest: Manifest): Promise { + async function errorsFor(manifest: Manifest): Promise { const logger = new CapturingLogger(); const useCase = new CleanUserScopeUseCase( new InMemoryFileAdapter(), @@ -906,12 +1635,16 @@ describe("clean --scope user", () => { new Map(), () => HOME ); - await useCase.execute({ projectRoot: "/wherever", force: true }); - return logger.warnMessages; + try { + await useCase.execute({ projectRoot: "/wherever", force: true }); + throw new Error("expected a fail-closed binary refusal"); + } catch (error) { + return [(error as Error).message]; + } } it("names no cache for a host whose profile declares no cache directory (copilot)", async () => { - const warnings = await warningsFor( + const warnings = await errorsFor( seedRegistrations( "copilot", [{ alias: "aidd-framework", hostName: "aidd-framework" }], @@ -920,32 +1653,32 @@ describe("clean --scope user", () => { ); expect(warnings).toStrictEqual([ - "copilot: registration left in place, the copilot CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 1 plugin ref(s).", + "copilot: registration left in place, the copilot CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 1 plugin ref(s). User clean refused; canonical claims retained.", ]); }); it("names no cache for a tool that drives no native CLI (cursor)", async () => { - const warnings = await warningsFor( + const warnings = await errorsFor( seedRegistrations("cursor", [{ alias: "aidd-framework", hostName: "aidd-framework" }], []) ); expect(warnings).toStrictEqual([ - "cursor: registration left in place, the cursor CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 0 plugin ref(s).", + "cursor: registration left in place, the cursor CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 0 plugin ref(s). User clean refused; canonical claims retained.", ]); }); it("names no cache when the binary registered no marketplace", async () => { - const warnings = await warningsFor( + const warnings = await errorsFor( seedRegistrations("codex", [], ["aidd-context@aidd-framework"]) ); expect(warnings).toStrictEqual([ - "codex: registration left in place, the codex CLI is not on the PATH. It would have unregistered 0 marketplace(s) and 1 plugin ref(s).", + "codex: registration left in place, the codex CLI is not on the PATH. It would have unregistered 0 marketplace(s) and 1 plugin ref(s). User clean refused; canonical claims retained.", ]); }); it("names every surviving cache, one path per marketplace", async () => { - const warnings = await warningsFor( + const warnings = await errorsFor( seedRegistrations( "codex", [ @@ -957,7 +1690,7 @@ describe("clean --scope user", () => { ); expect(warnings).toStrictEqual([ - `codex: registration left in place, the codex CLI is not on the PATH. It would have unregistered 2 marketplace(s) and 0 plugin ref(s). Its cache survives at: ${join(CODEX_CACHE, "mkt-a")}, ${join(CODEX_CACHE, "mkt-b")}.`, + `codex: registration left in place, the codex CLI is not on the PATH. It would have unregistered 2 marketplace(s) and 0 plugin ref(s). Its cache survives at: ${join(CODEX_CACHE, "mkt-a")}, ${join(CODEX_CACHE, "mkt-b")}. User clean refused; canonical claims retained.`, ]); }); }); @@ -998,6 +1731,55 @@ describe("clean --scope user", () => { }); describe("the cache/ shell around the whitelist", () => { + it("tolerates a cache shell already absent while still removing later whitelist files", async () => { + const fs = new StrictListingFileAdapter(); + const marker = join(USER_CONFIG_DIR, "update-check.json"); + fs.setFile(marker, "legacy cache"); + const logger = new CapturingLogger(); + const repo = new InMemoryManifestRepository(Manifest.create()); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + + const result = await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(result.dryRun).toBe(false); + expect(fs.has(marker)).toBe(false); + expect(repo.getCurrent()).toBeNull(); + expect(logger.infoMessages).not.toContain( + `user scope: cache purged: ${join(USER_CONFIG_DIR, "cache")}` + ); + }); + + it("propagates an unreadable cache shell and retains canonical state and later whitelist files", async () => { + const fs = new StrictListingFileAdapter(join(USER_CONFIG_DIR, "cache")); + const source = join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "catalogue.json"); + const legacy = join(USER_CONFIG_DIR, "update-check.json"); + fs.setFile(source, "built source"); + fs.setFile(legacy, "legacy cache"); + const machine = Manifest.create(); + const repo = new InMemoryManifestRepository(machine); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + "permission denied" + ); + + expect(fs.has(source)).toBe(false); + expect(fs.getFile(legacy)).toBe("legacy cache"); + expect(repo.getCurrent()).toBe(machine); + }); + it("leaves cache/ and everything under it in place, naming each, once cache/ resolves outside userConfigDir()", async () => { const fs = new RecordingFileAdapter(); const cacheDir = join(USER_CONFIG_DIR, "cache"); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-remove-native-claims.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-remove-native-claims.integration.test.ts new file mode 100644 index 000000000..f509b0d8a --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-remove-native-claims.integration.test.ts @@ -0,0 +1,389 @@ +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceRemoveUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-remove-use-case.js"; +import { ProjectPluginCleanup } from "../../../../../src/contexts/framework/application/ownership/project-plugin-cleanup.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { KeepPrompter, ScriptedPrompter } from "../../../../helpers/ports/scripted-prompter.js"; + +const REF = "sample-plugin@real-catalog"; +const OTHER = "other-plugin@other-catalog"; +const NAME = "project-alias"; + +async function fixture(options: { nativeMapping?: "absent" | "unrelated" } = {}) { + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "sample-plugin", + "1.0.0", + { kind: "local", path: "/fixture" }, + false, + "project", + NAME + ) + ); + if (options.nativeMapping !== "absent") + project.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + ...(options.nativeMapping === "unrelated" + ? [] + : [{ alias: NAME, hostName: "real-catalog" }]), + { alias: "other-alias", hostName: "other-catalog" }, + ], + pluginRefs: [REF, OTHER], + }); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: NAME, hostName: "real-catalog" }], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: ["/A", "/B"] }], + }); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: NAME, + source: { kind: "github", repo: "owner/project-alias" }, + scope: "project", + addedAt: "2026-09-01T00:00:00.000Z", + }) + ); + const fs = new InMemoryFileAdapter(); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs, machineRepo), + projectRepo, + registry, + new KeepPrompter() + ); + return { projectRepo, machineRepo, registry, fs, useCase }; +} + +describe("project marketplace removal against machine native claims", () => { + it.each(["absent", "unrelated"] as const)( + "removes a local orphan with %s native mapping without guessing or detaching machine refs", + async (nativeMapping) => { + const f = await fixture({ nativeMapping }); + const before = f.projectRepo.getCurrent()?.getNativeRegistrations("codex"); + expect( + await f.useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).toMatchObject({ removedPluginCount: 1, orphanCount: 1 }); + expect(f.projectRepo.getCurrent()?.getPlugins("codex")).toEqual([]); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")).toEqual(before); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + expect(f.machineRepo.saveCount).toBe(0); + expect(await f.registry.list("/A")).toEqual([]); + } + ); + + it("does not detach machine claims when removing a project catalogue without orphans", async () => { + const f = await fixture(); + f.projectRepo.getCurrent()?.removePlugin("codex", "sample-plugin"); + const before = f.projectRepo.getCurrent()?.getNativeRegistrations("codex"); + expect( + await f.useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).toMatchObject({ removedPluginCount: 0, orphanCount: 0 }); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")).toEqual(before); + expect(f.projectRepo.saveCount).toBe(0); + expect(f.machineRepo.saveCount).toBe(0); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + expect(await f.registry.list("/A")).toEqual([]); + }); + + it("retains A's projection and machine claim when its Cursor hooks cannot be read", async () => { + const hooksPath = join("/A", ".cursor/hooks.json"); + const hooks = JSON.stringify({ + version: 1, + hooks: { preToolUse: [{ command: "node ./.cursor/hooks/sample-plugin/pre.js" }] }, + }); + const fs = new FaultingFileAdapter(); + fs.setFile(hooksPath, hooks); + fs.setFile(join("/A", ".cursor/hooks/sample-plugin/pre.js"), "A's hook"); + fs.setFile(join("/B", ".cursor/hooks.json"), hooks); + const globalMcp = join(homedir(), ".cursor/plugins/local/sample-plugin/mcp.json"); + fs.setFile(globalMcp, "B's MCP"); + fs.failOn("readFile", hooksPath, errnoError("EACCES")); + const project = Manifest.create(); + project.addTool("cursor", "1.0.0", []); + project.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + marketplace: NAME, + projectHooks: { + entries: [ + { + event: "preToolUse", + command: "node ./.cursor/hooks/sample-plugin/pre.js", + digest: createHash("md5") + .update(JSON.stringify({ command: "node ./.cursor/hooks/sample-plugin/pre.js" })) + .digest("hex"), + }, + ], + scripts: { + ".cursor/hooks/sample-plugin/pre.js": createHash("md5") + .update("A's hook") + .digest("hex"), + }, + }, + }) + ); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + dependents: ["/A", "/B"], + }) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: NAME, + source: { kind: "local", path: "/fixture" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs, machineRepo), + projectRepo, + registry, + new KeepPrompter() + ); + + await expect( + useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).rejects.toThrow(/EACCES/); + + expect(projectRepo.saveCount).toBe(0); + expect(projectRepo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + expect(machineRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/A", "/B"]); + expect(fs.getFile(hooksPath)).toBe(hooks); + expect(fs.getFile(globalMcp)).toBe("B's MCP"); + expect((await registry.list("/A")).some((entry) => entry.name === NAME)).toBe(true); + }); + + it("refuses user scope at the project use-case edge without touching a catalogue", async () => { + const f = await fixture(); + await expect( + f.useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true, scope: "user" }) + ).rejects.toThrow(/User-scope marketplace removal requires/); + expect((await f.registry.list("/A")).find((entry) => entry.name === NAME)).toBeDefined(); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + }); + + it("removes only the project catalogue when its project manifest is absent", async () => { + const f = await fixture(); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(f.fs, f.machineRepo), + new InMemoryManifestRepository(), + f.registry, + new KeepPrompter() + ); + expect( + await useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).toMatchObject({ removedPluginCount: 0, orphanCount: 0 }); + expect((await f.registry.list("/A")).find((entry) => entry.name === NAME)).toBeUndefined(); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + }); + + it("deletes A's exact orphan projection but preserves B and a different host catalogue", async () => { + const f = await fixture(); + expect( + await f.useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).toMatchObject({ removedPluginCount: 1, orphanCount: 1 }); + expect(f.projectRepo.getCurrent()?.getPlugins("codex")).toEqual([]); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + OTHER, + ]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/B"] }, + ]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([REF]); + expect((await f.registry.list("/A")).find((entry) => entry.name === NAME)).toBeUndefined(); + }); + + it("does not detach B or claim a ref after a local file deletion failure", async () => { + class RefusingFileAdapter extends InMemoryFileAdapter { + override async deleteFile(): Promise { + throw new Error("local file delete failed"); + } + } + const f = await fixture(); + const project = f.projectRepo.getCurrent(); + const plugin = project?.getPlugins("codex")[0]; + if (project === null || plugin === undefined) throw new Error("fixture missing plugin"); + project.updatePlugin("codex", plugin.withFiles(new Map([[".codex/skills/demo.md", "abc"]]))); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(new RefusingFileAdapter(), f.machineRepo), + f.projectRepo, + f.registry, + new KeepPrompter() + ); + await expect( + useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).rejects.toThrow(/local file delete failed/); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + expect((await f.registry.list("/A")).find((entry) => entry.name === NAME)).toBeDefined(); + }); + + it("leaves a project plugin and B's machine claim when the operator declines orphan cleanup", async () => { + const f = await fixture(); + const prompter = new ScriptedPrompter([{ type: "confirm", value: false }]); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(f.fs, f.machineRepo), + f.projectRepo, + f.registry, + prompter + ); + expect( + await useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: false }) + ).toMatchObject({ removedPluginCount: 0, orphanCount: 1 }); + expect(f.projectRepo.getCurrent()?.getPlugins("codex")).toHaveLength(1); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + }); + + it("releases only A's Cursor file claim, leaving B's user bytes intact", async () => { + const project = Manifest.create(); + project.addTool("cursor", "1.0.0", []); + project.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + marketplace: NAME, + projectHooks: { + entries: [ + { + event: "preToolUse", + command: "node ./.cursor/hooks/sample-plugin/pre.js", + digest: createHash("md5") + .update(JSON.stringify({ command: "node ./.cursor/hooks/sample-plugin/pre.js" })) + .digest("hex"), + }, + ], + scripts: { + ".cursor/hooks/sample-plugin/pre.js": createHash("md5") + .update("A's hook") + .digest("hex"), + }, + }, + }) + ); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: { "sample-plugin/skills/demo.md": "abc" }, + scope: "user", + marketplace: NAME, + dependents: ["/A", "/B"], + }) + ); + const machineRepo = new InMemoryManifestRepository(machine); + const projectRepo = new InMemoryManifestRepository(project); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: NAME, + source: { kind: "github", repo: "owner/project-alias" }, + scope: "project", + addedAt: "2026-09-01T00:00:00.000Z", + }) + ); + const hooks = JSON.stringify({ + version: 1, + hooks: { + preToolUse: [{ command: "node ./.cursor/hooks/sample-plugin/pre.js" }], + }, + }); + const aHooks = join("/A", ".cursor/hooks.json"); + const bHooks = join("/B", ".cursor/hooks.json"); + const aScript = join("/A", ".cursor/hooks/sample-plugin/pre.js"); + const bScript = join("/B", ".cursor/hooks/sample-plugin/pre.js"); + const fs = new InMemoryFileAdapter({ + [aHooks]: hooks, + [bHooks]: hooks, + [aScript]: "A's hook", + [bScript]: "B's hook", + }); + const ownedPath = join(homedir(), ".cursor/plugins/local/sample-plugin/skills/demo.md"); + const ownedMcp = join(homedir(), ".cursor/plugins/local/sample-plugin/mcp.json"); + fs.setFile(ownedPath, "B's bytes"); + fs.setFile(ownedMcp, "B's MCP"); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs, machineRepo), + projectRepo, + registry, + new KeepPrompter() + ); + expect( + await useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).toMatchObject({ + removedPluginCount: 1, + }); + expect(fs.getFile(ownedPath)).toBe("B's bytes"); + expect(fs.getFile(ownedMcp)).toBe("B's MCP"); + expect(fs.getFile(aHooks)).toBeUndefined(); + expect(fs.getFile(aScript)).toBeUndefined(); + expect(fs.getFile(bHooks)).toBe(hooks); + expect(fs.getFile(bScript)).toBe("B's hook"); + expect(machineRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/B"]); + expect(projectRepo.getCurrent()?.getPlugins("cursor")).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts index 44c8a7d5e..1fb1cc57b 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts @@ -1,12 +1,15 @@ +import { createHash } from "node:crypto"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { FRAMEWORK_MARKETPLACE_NAME, Marketplace, } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { MarketplaceRemoveUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-remove-use-case.js"; +import { ProjectPluginCleanup } from "../../../../../src/contexts/framework/application/ownership/project-plugin-cleanup.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { @@ -82,7 +85,12 @@ function buildUseCase() { const fs = new RecordingFileAdapter({}, hasher); const manifestRepo = new InMemoryManifestRepository(); const registry = new InMemoryMarketplaceRegistry(); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, new KeepPrompter()); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + new KeepPrompter() + ); return { useCase, registry, manifestRepo, fs }; } @@ -157,9 +165,14 @@ describe("MarketplaceRemoveUseCase", () => { expect(reloaded?.getPlugins("claude")).toHaveLength(0); }); - it("removes a user-scope (cursor) orphan's file from its resolved home directory, not projectRoot", async () => { + it("removes a project marketplace's orphan projection without deleting shared Cursor files", async () => { const { registry, manifestRepo, fs } = buildUseCase(); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, new KeepPrompter()); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + new KeepPrompter() + ); const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); const pluginKey = "aidd-context/commands/hello.md"; @@ -196,13 +209,18 @@ describe("MarketplaceRemoveUseCase", () => { expect(result.removedPluginCount).toBe(1); expect( fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", pluginKey))) - ).toBe(true); + ).toBe(false); expect(fs.deletedPaths).not.toContain(join(PROJECT_ROOT, pluginKey)); }); it("removes a cursor orphan's file under projectRoot, not ~/.cursor/plugins/local, when the manifest says scope: project", async () => { const { registry, manifestRepo, fs } = buildUseCase(); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, new KeepPrompter()); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + new KeepPrompter() + ); const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); const pluginKey = "aidd-context/commands/hello.md"; @@ -321,6 +339,254 @@ describe("which marketplace a removal takes out", () => { }); describe("which plugins a removal orphans", () => { + it("keeps A and B Cursor claims when project hooks.json resolves outside the project", async () => { + const hooksPath = join(PROJECT_ROOT, ".cursor/hooks.json"); + const foreignPath = "/foreign/hooks.json"; + const command = "node ./.cursor/hooks/sample/pre.js"; + const entry = { command }; + const content = JSON.stringify({ version: 1, hooks: { preToolUse: [entry] } }); + const fs = new InMemoryFileAdapter({ [hooksPath]: content, [foreignPath]: content }); + fs.setSymlink(hooksPath, foreignPath); + const plugin = InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + marketplace: "awesome", + projectHooks: { + entries: [ + { + event: "preToolUse", + command, + digest: createHash("md5").update(JSON.stringify(entry)).digest("hex"), + }, + ], + scripts: {}, + }, + }); + const project = Manifest.create(); + project.addTool("cursor", "1.0.0", []); + project.addPlugin("cursor", plugin); + const projectRepo = new InMemoryManifestRepository(project); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin("cursor", plugin.withDependents([PROJECT_ROOT, "/B"])); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs, machineRepo), + projectRepo, + registry, + new KeepPrompter() + ); + + await expect( + useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: true }) + ).rejects.toThrow(/outside.*project/); + + expect(projectRepo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + expect(machineRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual([ + PROJECT_ROOT, + "/B", + ]); + expect(fs.getFile(foreignPath)).toBe(content); + expect((await registry.list(PROJECT_ROOT)).some((entry) => entry.name === "awesome")).toBe( + true + ); + }); + + it("retains the OpenCode orphan and catalogue when MCP output is a symlink outside the project", async () => { + const mcpPath = join(PROJECT_ROOT, "opencode.json"); + const foreignPath = "/foreign/opencode.json"; + const content = JSON.stringify({ + mcp: { mine: { type: "local" }, theirs: { type: "remote" } }, + }); + const fs = new InMemoryFileAdapter({ [mcpPath]: content, [foreignPath]: content }); + fs.setSymlink(mcpPath, foreignPath); + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + mine: new DeterministicHasher().hash(JSON.stringify({ type: "local" })).value, + }, + scope: "project", + marketplace: "awesome", + }) + ); + const manifestRepo = new InMemoryManifestRepository(manifest); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + new KeepPrompter() + ); + + await expect( + useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: true }) + ).rejects.toThrow(/outside.*project|escapes.*project/); + + expect(manifestRepo.saveCount).toBe(0); + expect(manifestRepo.getCurrent()?.getPlugins("opencode")).toHaveLength(1); + expect(fs.getFile(foreignPath)).toBe(content); + expect(fs.getFile(mcpPath)).toBe(content); + expect((await registry.list(PROJECT_ROOT)).some((entry) => entry.name === "awesome")).toBe( + true + ); + }); + + it("retains the OpenCode orphan and catalogue when its project MCP config cannot be rewritten", async () => { + const mcpPath = join(PROJECT_ROOT, "opencode.json"); + const content = JSON.stringify({ + mcp: { mine: { type: "local" }, theirs: { type: "remote" } }, + }); + class RefusingMcpWriteAdapter extends InMemoryFileAdapter { + override async writeFile(path: string, value: string): Promise { + if (path === mcpPath) throw new Error("MCP write refused"); + return super.writeFile(path, value); + } + } + const fs = new RefusingMcpWriteAdapter({ [mcpPath]: content }); + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + mine: new DeterministicHasher().hash(JSON.stringify({ type: "local" })).value, + }, + scope: "project", + marketplace: "awesome", + }) + ); + const manifestRepo = new InMemoryManifestRepository(manifest); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + new KeepPrompter() + ); + + await expect( + useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: true }) + ).rejects.toThrow(/MCP write refused/); + + expect(manifestRepo.saveCount).toBe(0); + expect(manifestRepo.getCurrent()?.getPlugins("opencode")).toHaveLength(1); + expect(fs.getFile(mcpPath)).toBe(content); + expect((await registry.list(PROJECT_ROOT)).some((entry) => entry.name === "awesome")).toBe( + true + ); + }); + + it("unmerges only the orphan's contributed OpenCode MCP server from the project", async () => { + const { useCase, registry, manifestRepo, fs } = buildUseCase(); + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + mine: new DeterministicHasher().hash(JSON.stringify({ type: "local" })).value, + }, + scope: "project", + marketplace: "awesome", + }) + ); + await manifestRepo.save(manifest); + const mcpPath = join(PROJECT_ROOT, "opencode.json"); + fs.setFile( + mcpPath, + JSON.stringify({ mcp: { mine: { type: "local" }, theirs: { type: "remote" } } }) + ); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + + fs.setFile( + mcpPath, + JSON.stringify({ mcp: { mine: { type: "user-edited" }, theirs: { type: "remote" } } }) + ); + await expect( + useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: true }) + ).rejects.toThrow(/edited/); + expect((await manifestRepo.load())?.getPlugins("opencode")).toHaveLength(1); + expect((await registry.list(PROJECT_ROOT)).some((entry) => entry.name === "awesome")).toBe( + true + ); + fs.setFile( + mcpPath, + JSON.stringify({ mcp: { mine: { type: "local" }, theirs: { type: "remote" } } }) + ); + + await useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: true }); + + expect(JSON.parse(fs.getFile(mcpPath) ?? "null")).toEqual({ + mcp: { theirs: { type: "remote" } }, + }); + expect((await manifestRepo.load())?.getPlugins("opencode")).toEqual([]); + }); + + it("preflights every orphan before touching A when B's MCP contribution was edited", async () => { + const { useCase, registry, manifestRepo, fs } = buildUseCase(); + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + for (const name of ["alpha", "beta"]) { + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name, + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + [name]: new DeterministicHasher().hash(JSON.stringify({ type: "local" })).value, + }, + scope: "project", + marketplace: "awesome", + }) + ); + } + await manifestRepo.save(manifest); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + const path = join(PROJECT_ROOT, "opencode.json"); + const edited = JSON.stringify({ + mcp: { alpha: { type: "local" }, beta: { type: "user-edited" } }, + }); + fs.setFile(path, edited); + await expect( + useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: true }) + ).rejects.toThrow(/edited.*beta|beta.*edited/); + expect(fs.getFile(path)).toBe(edited); + expect((await manifestRepo.load())?.getPlugins("opencode").map((p) => p.name)).toEqual([ + "alpha", + "beta", + ]); + expect((await registry.list(PROJECT_ROOT)).some((m) => m.name === "awesome")).toBe(true); + }); + it("removes the marketplace's own plugins and leaves another marketplace's in place", async () => { const { useCase, registry, manifestRepo } = buildUseCase(); await manifestRepo.save( @@ -344,7 +610,12 @@ describe("which plugins a removal orphans", () => { it("keeps every orphan when the person declines the cleanup, and still drops the marketplace", async () => { const { registry, manifestRepo, fs } = buildUseCase(); const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(false)]); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + prompter + ); await manifestRepo.save(manifestWithPlugins(pluginFrom("awesome", "sample"))); const awesome = marketplaceNamed("awesome"); await registry.save(PROJECT_ROOT, awesome); @@ -365,7 +636,12 @@ describe("which plugins a removal orphans", () => { it("asks once, naming how many plugins the cleanup would remove", async () => { const { registry, manifestRepo, fs } = buildUseCase(); const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(true)]); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + prompter + ); await manifestRepo.save( manifestWithPlugins(pluginFrom("awesome", "sample"), pluginFrom("awesome", "second")) ); @@ -389,7 +665,12 @@ describe("which plugins a removal orphans", () => { manifestWithPlugins(pluginFrom("elsewhere", "other")) ); const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(true)]); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + prompter + ); await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); await useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: false }); @@ -401,7 +682,12 @@ describe("which plugins a removal orphans", () => { it("cleans up without asking when the caller auto-confirms", async () => { const { registry, manifestRepo, fs } = buildUseCase(); const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(false)]); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + prompter + ); await manifestRepo.save(manifestWithPlugins(pluginFrom("awesome", "sample"))); await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts index fa8302a70..9a809e1ff 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts @@ -10,6 +10,8 @@ import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -43,6 +45,8 @@ interface Setup { * reported success but left nothing readable where its own tool profile probes. */ readonly omitRequestedCatalog?: boolean; readonly fs?: InMemoryFileAdapter; + /** A prior user manifest claim, not this project's plugin projection, proves catalogue ownership. */ + readonly ownedCatalog?: true; } class RealpathRefusingFileAdapter extends InMemoryFileAdapter { @@ -108,6 +112,35 @@ async function sync(setup: Setup = {}) { ? [] : ([[toolId, setup.hostReader]] as [AiToolId, FakeHostMarketplaceRegistryReader][]) ); + const hostReading = toolId === "claude" ? await setup.hostReader?.read() : undefined; + const registeredSource = hostReading?.entries?.get(catalogName); + const currentSource = + registeredSource === undefined + ? undefined + : toolId === "claude" + ? ({ kind: "registry", source: registeredSource } as const) + : ({ + kind: "effective-list", + root: registeredSource, + sourceType: "local", + source: registeredSource, + } as const); + const machine = setup.ownedCatalog ? Manifest.create() : undefined; + if (machine !== undefined) { + machine.addTool(toolId, "test", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [ + { + alias: aiddName, + hostName: catalogName, + ...(currentSource === undefined ? {} : { provenance: currentSource }), + }, + ], + pluginRefs: [], + pluginClaims: [], + }); + } const useCase = new MarketplaceSyncSettingsUseCase( fs, manifestRepo, @@ -116,7 +149,39 @@ async function sync(setup: Setup = {}) { logger, new Map([[toolId === "claude" ? "claude" : "codex", activator]]), fakeEnsureBuiltMarketplace((target) => `/built/${target}`), - hostRegistries + hostRegistries, + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + toolId, + new FakeHostPluginRegistryReader({ + location: `/home/${toolId}/plugins/installed_plugins.json`, + refs: new Map(), + }), + ], + ]), + machine === undefined ? undefined : new InMemoryManifestRepository(machine), + new Map([ + [ + toolId, + hostReading?.unreadable !== undefined + ? { + read: async () => ({ + location: hostReading.location, + unreadable: hostReading.unreadable, + }), + } + : new FakeNativeMarketplaceSourceReader( + activator, + toolId === "claude" ? "registry" : "effective-list", + (path) => (path === builtDir ? catalogName : undefined), + currentSource === undefined ? new Map() : new Map([[catalogName, currentSource]]) + ), + ], + ]) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); return { result, activator, manifestRepo, builtDir, catalogRelative }; @@ -153,6 +218,7 @@ describe("the sync guard against a marketplace name a host already holds", () => const { result, activator } = await sync({ hostReader, requestedVersion: "2.0.0", + ownedCatalog: true, requestedPluginNames: ["sample-plugin"], registeredCatalog: { path: "/other/src", version: "1.0.0", pluginNames: ["sample-plugin"] }, }); @@ -169,9 +235,9 @@ describe("the sync guard against a marketplace name a host already holds", () => entries: new Map([["probe-mkt", "/built/claude"]]), }); - const { result, activator } = await sync({ hostReader }); + const { result, activator } = await sync({ hostReader, ownedCatalog: true }); - expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(activator.addedMarketplaces).toEqual([]); expect(result.errors).toEqual([]); }); @@ -186,6 +252,7 @@ describe("the sync guard against a marketplace name a host already holds", () => const { result, activator } = await sync({ hostReader, requestedVersion: "1.0.0", + ownedCatalog: true, requestedPluginNames: ["sample-plugin"], registeredCatalog: { path: "/other-project/built/claude", @@ -198,7 +265,7 @@ describe("the sync guard against a marketplace name a host already holds", () => expect(result.errors).toEqual([]); }); - it("does not refuse when the registered source no longer resolves to a readable catalog — a dead entry a re-add repairs", async () => { + it("refuses an unproven dead entry instead of re-adding over its host name", async () => { const hostReader = new FakeHostMarketplaceRegistryReader({ location: REGISTRY_LOCATION, entries: new Map([["probe-mkt", "/gone"]]), @@ -208,7 +275,8 @@ describe("the sync guard against a marketplace name a host already holds", () => // catalog fails exactly as it would for a directory that no longer exists. const { result, activator } = await sync({ hostReader }); - expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings.join("\n")).toContain("legacy claim has no source proof"); expect(result.errors).toEqual([]); }); @@ -259,7 +327,7 @@ describe("the sync guard against a marketplace name a host already holds", () => { scope: "claude", message: - "Marketplace 'probe-mkt' is already registered from a different catalog: /other/src differs from the one requested, /built/claude — plugins differ (+sample-plugin, -different-plugin), per /home/.claude/plugins/known_marketplaces.json. Run `claude plugin marketplace remove probe-mkt`, then `aidd sync` again to re-register it for this project.", + "Marketplace 'probe-mkt' is already registered from a different catalog: /other/src differs from the one requested, /built/claude — plugins differ (+sample-plugin, -different-plugin), per /home/.claude/plugins/known_marketplaces.json. Do not remove a catalogue another user or project may depend on. Reconcile the host source manually before retrying `aidd sync`.", }, ]); }); @@ -267,12 +335,14 @@ describe("the sync guard against a marketplace name a host already holds", () => it("registers from the built path as given when that path cannot be resolved", async () => { const hostReader = new FakeHostMarketplaceRegistryReader({ location: REGISTRY_LOCATION, - entries: new Map([["probe-mkt", "/built/claude"]]), + entries: new Map([["probe-mkt", "/old/claude"]]), }); const { result, activator } = await sync({ hostReader, fs: new RealpathRefusingFileAdapter("/built/claude"), + ownedCatalog: true, + registeredCatalog: { path: "/old/claude" }, }); expect(result.errors).toStrictEqual([]); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-machine-projection.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-machine-projection.integration.test.ts new file mode 100644 index 000000000..4379b64af --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-machine-projection.integration.test.ts @@ -0,0 +1,812 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSource } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +describe("native plugin ownership follows the host's activation scope", () => { + const freshNativeSources = ( + toolId: "claude" | "codex", + activator: FakeNativePluginActivator, + identities: ReadonlyMap, + initial: ReadonlyMap = new Map() + ) => + new Map([ + [ + toolId, + new FakeNativeMarketplaceSourceReader( + activator, + toolId === "claude" ? "registry" : "effective-list", + (path) => identities.get(path), + initial + ), + ], + ]); + const readablePlugins = (toolId: "claude" | "codex") => + new Map([ + [ + toolId, + new FakeHostPluginRegistryReader({ + location: `/home/${toolId}/plugins/installed_plugins.json`, + refs: new Map(), + }), + ], + ]); + const freshHostCatalogs = () => + new Map([ + [ + "claude" as const, + new FakeHostMarketplaceRegistryReader({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map(), + }), + ], + ]); + const readableHostPlugins = () => + new Map([ + [ + "claude" as const, + new FakeHostPluginRegistryReader({ + location: "/home/.claude/plugins/installed_plugins.json", + refs: new Map(), + }), + ], + ]); + it.each(["fresh", "existing project proof", "existing machine proof"] as const)( + "records a user-scope Claude catalogue without claiming its project-local plugin: %s", + async (mode) => { + const projectRoot = "/project"; + const marketplaceName = "local-catalog"; + const hostName = "real-catalog"; + const ref = `test-plugin@${hostName}`; + const otherRef = "owned-other@other-catalog"; + const otherRegistration = { + alias: "other", + hostName: "other-catalog", + provenance: { kind: "registry" as const, source: "/other/claude" }, + }; + const registration = { + alias: marketplaceName, + hostName, + provenance: { kind: "registry" as const, source: "/built/claude" }, + }; + const project = Manifest.create(); + project.addTool("claude", "1.0.0", []); + project.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + marketplaceName + ) + ); + const machine = Manifest.create(); + if (mode !== "fresh") { + project.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: mode === "existing project proof" ? [otherRegistration, registration] : [], + pluginRefs: [ref], + }); + machine.addTool("claude", "1.0.0", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: + mode === "existing machine proof" + ? [otherRegistration, registration] + : [otherRegistration], + pluginRefs: [otherRef], + pluginClaims: [{ ref: otherRef, dependents: ["/B"] }], + }); + } + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: marketplaceName, + source: { kind: "local", path: "/source" }, + scope: "user", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const fs = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: hostName, + version: "1.0.0", + plugins: [{ name: "test-plugin" }], + }), + }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + mode === "fresh" + ? freshHostCatalogs() + : new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([ + [hostName, "/built/claude"], + ["other-catalog", "/other/claude"], + ]), + }), + ], + ]), + () => "", + undefined, + undefined, + undefined, + mode === "fresh" + ? readableHostPlugins() + : new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/home/.claude/plugins/installed_plugins.json", + refs: new Map([ + [ref, { enabled: true, scope: "project" }], + [otherRef, { enabled: true, scope: "user" }], + ["test-plugin@external-catalog", { enabled: true, scope: "user" }], + ]), + }), + ], + ]), + machineRepo, + freshNativeSources( + "claude", + activator, + new Map([["/built/claude", hostName]]), + mode === "fresh" + ? new Map() + : new Map([ + [hostName, registration.provenance], + ["other-catalog", otherRegistration.provenance], + ]) + ) + ); + + const result = await sync.execute({ projectRoot }); + + expect(result.activated).toEqual(["claude"]); + expect(result.errors).toStrictEqual([]); + expect(result.warnings).toStrictEqual([]); + expect(activator.enabledPlugins).toEqual(mode === "fresh" ? [ref] : []); + expect(activator.enabledPluginScopes).toEqual(mode === "fresh" ? ["project"] : []); + expect(activator.addedMarketplaces).toStrictEqual(mode === "fresh" ? ["/built/claude"] : []); + expect(activator.removedMarketplaces).toStrictEqual([]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginRefs).toEqual([ref]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("claude")?.marketplaces).toEqual( + mode === "fresh" ? [registration] : [otherRegistration, registration] + ); + expect( + machineRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims ?? [] + ).toEqual(mode === "fresh" ? [] : [{ ref: otherRef, dependents: ["/B"] }]); + if (mode !== "fresh") + expect( + machineRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginRefs + ).toStrictEqual([otherRef]); + } + ); + + it("claims a Claude plugin enabled at user scope without inventing a project dependent", async () => { + const projectRoot = "/project"; + const alias = "user-catalog"; + const ref = "test-plugin@real-catalog"; + const user = Manifest.create(); + user.addTool("claude", "1.0.0", []); + user.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "user", + alias + ) + ); + const userRepo = new InMemoryManifestRepository(user); + const projectRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: alias, + source: { kind: "local", path: "/source" }, + scope: "user", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const fs = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: "real-catalog", + version: "1.0.0", + plugins: [{ name: "test-plugin" }], + }), + }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + freshHostCatalogs(), + () => "", + undefined, + undefined, + undefined, + readablePlugins("claude"), + userRepo, + freshNativeSources("claude", activator, new Map([["/built/claude", "real-catalog"]])) + ); + + const result = await sync.execute({ projectRoot, scope: "user", manifestRepo: userRepo }); + + expect(result.errors).toEqual([]); + expect(activator.enabledPlugins).toEqual([ref]); + expect(activator.enabledPluginScopes).toEqual(["user"]); + expect(userRepo.getCurrent()?.getNativeRegistrations("claude")?.marketplaces).toEqual([ + { + alias, + hostName: "real-catalog", + provenance: { kind: "registry", source: "/built/claude" }, + }, + ]); + expect(userRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual([ + { ref, dependents: [] }, + ]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("claude")).toBeUndefined(); + }); + + it("records only the user-scope marketplace in the machine manifest when a project syncs both scopes", async () => { + const projectRoot = "/project"; + const project = Manifest.create(); + project.addTool("claude", "1.0.0", []); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + for (const [name, scope] of [ + ["user-alias", "user"], + ["project-alias", "project"], + ] as const) { + await registry.save( + projectRoot, + Marketplace.create({ + name, + source: { kind: "local", path: `/source/${name}` }, + scope, + addedAt: "2026-09-15T00:00:00Z", + }) + ); + } + const fs = new InMemoryFileAdapter({ + "/built/user-alias/.claude-plugin/marketplace.json": JSON.stringify({ + name: "real-user", + version: "1.0.0", + plugins: [], + }), + "/built/project-alias/.claude-plugin/marketplace.json": JSON.stringify({ + name: "real-project", + version: "1.0.0", + plugins: [], + }), + }); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + { + execute: async ({ marketplace }) => ({ + builtDir: `/built/${marketplace.name}`, + version: "1.0.0", + rebuilt: true, + }), + }, + freshHostCatalogs(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + machineRepo, + freshNativeSources( + "claude", + activator, + new Map([ + ["/built/user-alias", "real-user"], + ["/built/project-alias", "real-project"], + ]) + ) + ); + + const result = await sync.execute({ projectRoot }); + + expect(result.errors).toEqual([]); + const projectMarketplaces = projectRepo + .getCurrent() + ?.getNativeRegistrations("claude")?.marketplaces; + expect(projectMarketplaces).toHaveLength(2); + expect(projectMarketplaces).toEqual( + expect.arrayContaining([ + { + alias: "user-alias", + hostName: "real-user", + provenance: { kind: "registry", source: "/built/user-alias" }, + }, + { + alias: "project-alias", + hostName: "real-project", + provenance: { kind: "registry", source: "/built/project-alias" }, + }, + ]) + ); + expect(machineRepo.getCurrent()?.getNativeRegistrations("claude")?.marketplaces).toEqual([ + { + alias: "user-alias", + hostName: "real-user", + provenance: { kind: "registry", source: "/built/user-alias" }, + }, + ]); + }); + + it("records a Codex plugin as machine-owned even when its marketplace source is project-local", async () => { + const projectRoot = "/codex-project"; + const marketplaceName = "local-catalog"; + const ref = "test-plugin@real-catalog"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + marketplaceName + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + const older = { + alias: marketplaceName, + hostName: "older-catalog", + provenance: { + kind: "effective-list" as const, + root: "/older", + sourceType: "local", + source: "/older", + }, + }; + const otherRef = "unrelated-plugin@older-catalog"; + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [older], + pluginRefs: [otherRef], + pluginClaims: [ + { ref: otherRef, dependents: ["/B"] }, + { ref, dependents: ["/B"] }, + ], + }); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: marketplaceName, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "real-catalog", + version: "1.0.0", + plugins: [{ name: "test-plugin" }], + }), + }); + fs.setSymlink(projectRoot, "/resolved-codex-project"); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readablePlugins("codex"), + machineRepo, + freshNativeSources("codex", activator, new Map([["/built/codex", "real-catalog"]])) + ); + + const result = await sync.execute({ projectRoot }); + + expect(result.activated).toEqual(["codex"]); + expect(activator.enabledPlugins).toEqual([ref]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ref]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: otherRef, dependents: ["/B"] }, + { ref, dependents: ["/B", "/resolved-codex-project"] }, + ]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + otherRef, + ]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toEqual([ + older, + { + alias: marketplaceName, + hostName: "real-catalog", + provenance: { + kind: "effective-list", + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }, + }, + ]); + + await sync.execute({ projectRoot }); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: otherRef, dependents: ["/B"] }, + { ref, dependents: ["/B", "/resolved-codex-project"] }, + ]); + }); + + it("preserves another machine-owned marketplace claim during a narrowed sync", async () => { + const projectRoot = "/codex-project"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + for (const alias of ["market-a", "market-b"]) { + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + `plugin-${alias.at(-1)}`, + "1.0.0", + { kind: "local", path: `/source/${alias}` }, + true, + "project", + alias + ) + ); + } + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + for (const alias of ["market-a", "market-b"]) { + await registry.save( + projectRoot, + Marketplace.create({ + name: alias, + source: { kind: "local", path: `/source/${alias}` }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + } + const fs = new InMemoryFileAdapter({ + "/built/market-a/.agents/plugins/marketplace.json": JSON.stringify({ + name: "real-a", + version: "1.0.0", + plugins: [{ name: "plugin-a" }], + }), + "/built/market-b/.agents/plugins/marketplace.json": JSON.stringify({ + name: "real-b", + version: "1.0.0", + plugins: [{ name: "plugin-b" }], + }), + }); + const activator = new FakeNativePluginActivator({ available: true }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + { + execute: async ({ marketplace }) => ({ + builtDir: `/built/${marketplace.name}`, + version: "1.0.0", + rebuilt: true, + }), + }, + new Map(), + () => "", + undefined, + undefined, + undefined, + readablePlugins("codex"), + machineRepo, + freshNativeSources( + "codex", + activator, + new Map([ + ["/built/market-a", "real-a"], + ["/built/market-b", "real-b"], + ]) + ) + ); + + await sync.execute({ projectRoot, marketplaceNames: ["market-b"] }); + await sync.execute({ projectRoot, marketplaceNames: ["market-a"] }); + + expect(activator.enabledPlugins).toEqual(["plugin-b@real-b", "plugin-a@real-a"]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + "plugin-b@real-b", + "plugin-a@real-a", + ]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: "plugin-b@real-b", dependents: [projectRoot] }, + { ref: "plugin-a@real-a", dependents: [projectRoot] }, + ]); + }); + + it.each([ + { name: "binary unavailable", available: false, failOnPlugins: [] }, + { name: "host enable refused", available: true, failOnPlugins: ["test-plugin@real-catalog"] }, + ])("does not add a project dependency after $name", async (failure) => { + const projectRoot = "/B"; + const alias = "local-catalog"; + const ref = "test-plugin@real-catalog"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + alias + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias, hostName: "real-catalog" }], + pluginRefs: [], + pluginClaims: [{ ref, dependents: ["/A"] }], + }); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: alias, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator(failure); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "real-catalog", + version: "1.0.0", + plugins: [{ name: "test-plugin" }], + }), + }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readablePlugins("codex"), + machineRepo, + freshNativeSources("codex", activator, new Map([["/built/codex", "real-catalog"]])) + ); + + const result = await sync.execute({ projectRoot }); + + expect(result.errors).toEqual([]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs ?? []).toEqual([]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref, dependents: ["/A"] }, + ]); + if (failure.available) { + expect(result.warnings).toEqual([expect.stringContaining("enable plugin")]); + expect(activator.enabledPlugins).toEqual([]); + } else { + expect(result.binaryMissing).toEqual([{ toolId: "codex", binary: "codex" }]); + expect(activator.addedMarketplaces).toEqual([]); + } + }); + + it("does not enable or claim a global ref when the host plugin registry cannot be read", async () => { + const projectRoot = "/B"; + const alias = "local-catalog"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + alias + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: alias, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "real-catalog", + version: "1.0.0", + plugins: [{ name: "test-plugin" }], + }), + }); + const hostReader: HostPluginRegistryReader = { + read: async () => { + throw new Error("host plugin registry unreadable"); + }, + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([["codex", hostReader]]), + machineRepo + ); + + const result = await sync.execute({ projectRoot }); + + expect(result.errors).toEqual([{ scope: "codex", message: "host plugin registry unreadable" }]); + expect(activator.enabledPlugins).toEqual([]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs ?? []).toEqual([]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims ?? []).toEqual( + [] + ); + }); + + it("does not attach to a foreign enabled host ref just because another catalog has the same plugin name", async () => { + const projectRoot = "/B"; + const alias = "local-catalog"; + const requestedRef = "test-plugin@real-catalog"; + const otherRef = "test-plugin@other-catalog"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + alias + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: "other", hostName: "other-catalog" }], + pluginRefs: [], + pluginClaims: [{ ref: otherRef, dependents: ["/A"] }], + }); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: alias, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "real-catalog", + version: "1.0.0", + plugins: [{ name: "test-plugin" }], + }), + }); + const hostReader: HostPluginRegistryReader = { + read: async () => ({ + location: "/host/registry", + refs: new Map([[requestedRef, { enabled: true }]]), + }), + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([["codex", hostReader]]), + machineRepo + ); + + const result = await sync.execute({ projectRoot }); + + expect(result.errors).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: otherRef, dependents: ["/A"] }, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts index db26b9401..c9fdcf35f 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts @@ -11,6 +11,8 @@ import { Manifest } from "../../../../../src/contexts/framework/domain/manifest. import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -100,7 +102,38 @@ function build(activator: FakeNativePluginActivator) { hasher, new CapturingLogger(), new Map([["claude", activator]]), - ensureBuiltPerMarketplace() + ensureBuiltPerMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/host/installed_plugins.json", + refs: new Map(), + }), + ], + ]), + undefined, + new Map([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => + path === "/built/market-a" + ? "market-a" + : path === "/built/market-b" + ? "market-b" + : undefined, + new Map() + ), + ], + ]) ), }; } @@ -131,7 +164,13 @@ describe("marketplaceNames narrows a sync run to the marketplaces named", () => const reloaded = await manifestRepo.load(); const registrations = reloaded?.getNativeRegistrations("claude"); expect(registrations?.marketplaces).toEqual( - expect.arrayContaining([{ alias: "market-a", hostName: "market-a" }]) + expect.arrayContaining([ + { + alias: "market-a", + hostName: "market-a", + provenance: { kind: "registry", source: "/built/market-a" }, + }, + ]) ); expect(registrations?.pluginRefs).toEqual(expect.arrayContaining(["plugin-a@market-a"])); }); @@ -195,7 +234,13 @@ describe("marketplaceNames narrows a sync run to the marketplaces named", () => expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ binary: "claude", - marketplaces: [{ alias: "market-b", hostName: "market-b" }], + marketplaces: [ + { + alias: "market-b", + hostName: "market-b", + provenance: { kind: "registry", source: "/built/market-b" }, + }, + ], pluginRefs: ["plugin-b@market-b"], }); }); @@ -206,8 +251,16 @@ describe("marketplaceNames narrows a sync run to the marketplaces named", () => manifest.setNativeRegistrations("claude", { binary: "claude", marketplaces: [ - { alias: "market-a", hostName: "market-a" }, - { alias: "market-b", hostName: "market-b" }, + { + alias: "market-a", + hostName: "market-a", + provenance: { kind: "registry", source: "/built/market-a" }, + }, + { + alias: "market-b", + hostName: "market-b", + provenance: { kind: "registry", source: "/built/market-b" }, + }, ], pluginRefs: ["plugin-a@market-a", "plugin-b-old@market-b"], }); @@ -223,8 +276,16 @@ describe("marketplaceNames narrows a sync run to the marketplaces named", () => expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ binary: "claude", marketplaces: [ - { alias: "market-a", hostName: "market-a" }, - { alias: "market-b", hostName: "market-b" }, + { + alias: "market-a", + hostName: "market-a", + provenance: { kind: "registry", source: "/built/market-a" }, + }, + { + alias: "market-b", + hostName: "market-b", + provenance: { kind: "registry", source: "/built/market-b" }, + }, ], pluginRefs: ["plugin-a@market-a", "plugin-b@market-b"], }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts index d51217e9b..9bedabcc6 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts @@ -12,9 +12,12 @@ import type { HostPluginRegistryReader, HostPluginRegistryReading, } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import { builtMarketplaceDir, userBuiltMarketplaceDir } from "../../../../../src/kernel/paths.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -63,6 +66,48 @@ function seededBuiltCatalog(): InMemoryFileAdapter { }); } +function readableHostPlugins(refs: readonly string[] = []) { + return new Map([ + [ + "claude" as const, + new FakeHostPluginRegistryReader({ + location: "/home/dev/.claude/plugins/installed_plugins.json", + refs: new Map(refs.map((ref) => [ref, { enabled: true }])), + }), + ], + ]); +} + +function nativeSource( + activator: FakeNativePluginActivator, + name = MARKETPLACE, + initial: ReadonlyMap = new Map() +) { + return new Map([ + [ + "claude" as const, + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === "/built/claude" ? name : undefined), + initial + ), + ], + ]); +} + +function ownedMachineCatalog(hostName: string, source: string): InMemoryManifestRepository { + const machine = Manifest.create(); + machine.addTool("claude", "test", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: MARKETPLACE, hostName, provenance: { kind: "registry", source } }], + pluginRefs: [], + pluginClaims: [], + }); + return new InMemoryManifestRepository(machine); +} + function buildSync(activator: FakeNativePluginActivator, pluginMarketplace?: string) { const registry = new InMemoryMarketplaceRegistry(); const fs = seededBuiltCatalog(); @@ -80,7 +125,15 @@ function buildSync(activator: FakeNativePluginActivator, pluginMarketplace?: str hasher, new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + nativeSource(activator) ), }; } @@ -190,7 +243,13 @@ describe("nativeRegistrations reflects what the host's own CLI was asked to regi const reloaded = await manifestRepo.load(); expect(reloaded?.getNativeRegistrations("claude")).toEqual({ binary: "claude", - marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], pluginRefs: [REF], }); }); @@ -212,7 +271,13 @@ describe("nativeRegistrations reflects what the host's own CLI was asked to regi const staleManifest = await manifestRepo.load(); staleManifest?.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], pluginRefs: [REF], }); if (staleManifest) await manifestRepo.save(staleManifest); @@ -224,7 +289,13 @@ describe("nativeRegistrations reflects what the host's own CLI was asked to regi const reloaded = await manifestRepo.load(); expect(reloaded?.getNativeRegistrations("claude")).toEqual({ binary: "claude", - marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], pluginRefs: [REF], }); }); @@ -251,7 +322,15 @@ describe("nativeRegistrations reflects what the host's own CLI was asked to regi hasher, new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + nativeSource(activator, CATALOG_NAME) ); await registry.save(PROJECT_ROOT, marketplace()); @@ -262,7 +341,13 @@ describe("nativeRegistrations reflects what the host's own CLI was asked to regi const reloaded = await manifestRepo.load(); expect(reloaded?.getNativeRegistrations("claude")).toEqual({ binary: "claude", - marketplaces: [{ alias: MARKETPLACE, hostName: CATALOG_NAME }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: CATALOG_NAME, + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], pluginRefs: [`${PLUGIN}@${CATALOG_NAME}`], }); }); @@ -286,7 +371,15 @@ describe("registering a marketplace does not wait for a plugin to point at it", hasher, new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + nativeSource(activator) ); await registry.save(PROJECT_ROOT, marketplace()); @@ -313,7 +406,15 @@ describe("what native activation leaves behind is not reported as the user's dri hasher, new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + nativeSource(activator) ); await registry.save(PROJECT_ROOT, marketplace()); @@ -394,7 +495,36 @@ describe("reclaiming a dead registration asks and acts on the host's own name", hasher, new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([ + [CATALOG_NAME, builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude")], + ]), + }), + }, + ], + ]), + () => "", + undefined, + undefined, + undefined, + new Map([["claude", { read: async () => hostReadingOf([]) }]]), + ownedMachineCatalog(CATALOG_NAME, builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude")), + nativeSource( + activator, + CATALOG_NAME, + new Map([ + [ + CATALOG_NAME, + { kind: "registry", source: builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude") }, + ], + ]) + ) ); await registry.save(PROJECT_ROOT, marketplace()); @@ -509,7 +639,15 @@ describe("an unnarrowed run replaces the whole recorded entry (lot 9 review C-B1 hasher, new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + nativeSource(activator, LIVE_MARKETPLACE) ); return { useCase, registry, manifestRepo }; } @@ -547,10 +685,10 @@ describe("a narrowed run preserves another alias's refs at a shared hostName (lo const PLUGIN_X = "plugin-x"; const PLUGIN_Y = "plugin-y"; - function ensureBuiltKeyedByMarketplace(): EnsureBuiltMarketplace { + function ensureBuiltSharedCatalog(): EnsureBuiltMarketplace { return { - execute: async (options) => ({ - builtDir: `/built/by-alias/${options.marketplace.name}`, + execute: async () => ({ + builtDir: "/built/by-alias/shared", version: "test", rebuilt: true, }), @@ -570,15 +708,10 @@ describe("a narrowed run preserves another alias's refs at a shared hostName (lo const activator = new FakeNativePluginActivator({ available: true }); const registry = new InMemoryMarketplaceRegistry(); const fs = new InMemoryFileAdapter({ - [`/built/by-alias/${ALIAS_X}/.claude-plugin/marketplace.json`]: JSON.stringify({ - name: SHARED_HOST_NAME, - version: "1.0.0", - plugins: [{ name: PLUGIN_X }], - }), - [`/built/by-alias/${ALIAS_Y}/.claude-plugin/marketplace.json`]: JSON.stringify({ + "/built/by-alias/shared/.claude-plugin/marketplace.json": JSON.stringify({ name: SHARED_HOST_NAME, version: "1.0.0", - plugins: [{ name: PLUGIN_Y }], + plugins: [{ name: PLUGIN_X }, { name: PLUGIN_Y }], }), }); const manifest = Manifest.create(); @@ -614,7 +747,25 @@ describe("a narrowed run preserves another alias's refs at a shared hostName (lo hasher, new CapturingLogger(), new Map([["claude", activator]]), - ensureBuiltKeyedByMarketplace() + ensureBuiltSharedCatalog(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + new Map([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === "/built/by-alias/shared" ? SHARED_HOST_NAME : undefined), + new Map() + ), + ], + ]) ); await registry.save(PROJECT_ROOT, aliasMarketplace(ALIAS_X)); await registry.save(PROJECT_ROOT, aliasMarketplace(ALIAS_Y)); @@ -629,7 +780,11 @@ describe("a narrowed run preserves another alias's refs at a shared hostName (lo const recorded = (await manifestRepo.load())?.getNativeRegistrations("claude"); expect(recorded?.pluginRefs).toContain(`${PLUGIN_Y}@${SHARED_HOST_NAME}`); - expect(recorded?.marketplaces).toContainEqual({ alias: ALIAS_Y, hostName: SHARED_HOST_NAME }); + expect(recorded?.marketplaces).toContainEqual({ + alias: ALIAS_Y, + hostName: SHARED_HOST_NAME, + provenance: { kind: "registry", source: "/built/by-alias/shared" }, + }); }); }); @@ -638,14 +793,25 @@ describe("what reclaiming a dead registration says", () => { const RECLAIM = "Marketplace 'aidd-framework-catalog' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back."; - function reclaimSync(activator: FakeNativePluginActivator) { + function reclaimSync( + activator: FakeNativePluginActivator, + hostSource = builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude"), + userRoot = "" + ) { const registry = new InMemoryMarketplaceRegistry(); - const fs = new InMemoryFileAdapter({ + class UserRootThatCannotResolve extends InMemoryFileAdapter { + override async realpath(path: string): Promise { + if (userRoot !== "" && path === userRoot) throw new Error("ENOENT: user cache is absent"); + return super.realpath(path); + } + } + const fs = new UserRootThatCannotResolve({ "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ name: CATALOG_NAME, version: "1.0.0", plugins: [], }), + "/foreign/cache/marker": "foreign bytes", }); const logger = new CapturingLogger(); const useCase = new MarketplaceSyncSettingsUseCase( @@ -655,11 +821,92 @@ describe("what reclaiming a dead registration says", () => { new DeterministicHasher(), logger, new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([[CATALOG_NAME, hostSource]]), + }), + }, + ], + ]), + () => userRoot, + undefined, + undefined, + undefined, + new Map([["claude", { read: async () => hostReadingOf([]) }]]), + ownedMachineCatalog( + CATALOG_NAME, + hostSource === "/foreign/cache" + ? builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude") + : hostSource + ), + nativeSource( + activator, + CATALOG_NAME, + new Map([[CATALOG_NAME, { kind: "registry", source: hostSource }]]) + ) ); - return { useCase, registry, logger }; + return { useCase, registry, logger, fs }; } + it("leaves a foreign host source and its cache untouched even with a past AIDD claim", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "dead", + }); + const { useCase, registry, fs } = reclaimSync(activator, "/foreign/cache"); + await registry.save(PROJECT_ROOT, marketplace()); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.warnings.join("\n")).toContain( + "current host source differs from AIDD's recorded source" + ); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.forcedRemovals).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(await fs.readFile("/foreign/cache/marker")).toBe("foreign bytes"); + }); + + it("does not force-remove a live registration even when its source is proven", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "live", + }); + const { useCase, registry } = reclaimSync(activator); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.forcedRemovals).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + }); + + it("reclaims a dead registration from an exact AIDD shared user-cache source", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "dead", + }); + const userRoot = "/home/aidd"; + const source = userBuiltMarketplaceDir(userRoot, "1.0.0", MARKETPLACE, "claude"); + const { useCase, registry } = reclaimSync(activator, source, userRoot); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([CATALOG_NAME]); + expect(activator.forcedRemovals).toEqual([true]); + expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + }); + it("names the vanished directory's own registration in the one warning it gives", async () => { const activator = new FakeNativePluginActivator({ available: true, @@ -690,8 +937,126 @@ describe("what reclaiming a dead registration says", () => { expect(result.warnings).toStrictEqual([ RECLAIM, "Native plugin activation — unregister stale marketplace 'aidd-framework-catalog' skipped: marketplace remove aidd-framework-catalog failed: 'aidd-framework-catalog' is not configured or installed", - "Native plugin activation — register marketplace 'aidd-framework-catalog' skipped: marketplace is already added from a different source; remove it before adding this source", ]); + expect(activator.addedMarketplaces).toEqual([]); expect(result.errors).toStrictEqual([]); }); }); + +function hostReadingOf(refs: readonly string[], enabled = true): HostPluginRegistryReading { + return { + location: "/home/dev/.claude/plugins/installed_plugins.json", + refs: new Map(refs.map((ref) => [ref, { enabled }])), + }; +} + +function buildSyncReadingHost( + activator: FakeNativePluginActivator, + hostRegistry: HostPluginRegistryReader, + catalogOwned = false +) { + const registry = new InMemoryMarketplaceRegistry(); + const manifestRepo = manifestWithPlugin(); + const logger = new CapturingLogger(); + const machine = catalogOwned ? Manifest.create() : undefined; + if (machine !== undefined) { + machine.addTool("claude", "test", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], + pluginRefs: [], + pluginClaims: [], + }); + } + const useCase = new MarketplaceSyncSettingsUseCase( + seededBuiltCatalog(), + manifestRepo, + registry, + new DeterministicHasher(), + logger, + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([["claude", hostRegistry]]), + machine === undefined ? undefined : new InMemoryManifestRepository(machine), + nativeSource( + activator, + MARKETPLACE, + catalogOwned + ? new Map([[MARKETPLACE, { kind: "registry", source: "/built/claude" }]]) + : new Map() + ) + ); + return { useCase, registry, manifestRepo, logger }; +} + +describe("a ref the host had enabled before this project asked belongs to the person", () => { + it("is neither enabled again nor recorded, so clean has nothing of it to undo", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifestRepo, logger } = buildSyncReadingHost(activator, { + read: async () => hostReadingOf([REF]), + }); + await registry.save(PROJECT_ROOT, marketplace()); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.enabledPlugins).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings.join("\n")).toContain(`foreign host ref '${REF}'`); + expect(logger.warnMessages).toEqual(result.warnings); + const recorded = (await manifestRepo.load())?.getNativeRegistrations("claude"); + expect(recorded?.pluginRefs).toEqual([]); + }); + + it("stays this project's own on the next sync, once the host reports the enable it made", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifestRepo } = buildSyncReadingHost( + activator, + { + read: async () => hostReadingOf(activator.enabledPlugins), + }, + true + ); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const recorded = (await manifestRepo.load())?.getNativeRegistrations("claude"); + expect(recorded?.pluginRefs).toEqual([REF]); + }); + + it.each([ + [ + "cannot be read", + { location: "/home/dev/.claude/plugins/installed_plugins.json", unreadable: "ENOENT" }, + ], + ["lists it disabled", hostReadingOf([REF], false)], + ])("refuses to claim a ref when the host registry %s", async (_case, reading) => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifestRepo } = buildSyncReadingHost(activator, { + read: async () => reading, + }); + await registry.save(PROJECT_ROOT, marketplace()); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.enabledPlugins).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings.some((warning) => warning.includes("registration left untouched"))).toBe( + true + ); + const recorded = (await manifestRepo.load())?.getNativeRegistrations("claude"); + expect(recorded?.pluginRefs).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-native-provenance.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-provenance.integration.test.ts new file mode 100644 index 000000000..705691de2 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-provenance.integration.test.ts @@ -0,0 +1,549 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import type { AiToolId } from "../../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/project-a"; +const CATALOG = "aidd-framework"; +const PLUGIN_REF = `aidd-dev@${CATALOG}`; +const FOREIGN_REF = `someone-elses-plugin@${CATALOG}`; +const CACHE_WITNESS = "/fake-home/host-cache/aidd-framework/foreign-plugin/bytes"; +const HOST_CATALOG_WITNESS = "/fake-home/host-catalog/aidd-framework/marketplace.json"; +const FOREIGN_CATALOG_BYTES = '{"name":"aidd-framework","source":"foreign-repo","marker":7}'; + +class NamedActivator extends FakeNativePluginActivator { + readonly refreshedCatalogs: string[] = []; + + override upgradeMarketplaces(name?: string): void { + this.refreshedCatalogs.push(name ?? ""); + super.upgradeMarketplaces(); + } +} + +function makeSync( + toolId: "codex" | "copilot", + options: { + machine?: Manifest; + hostRefs?: ReadonlyMap; + conflictOnAdd?: boolean; + projectNativePluginRefs?: readonly string[]; + hostReader?: HostPluginRegistryReader; + sourceReader?: NativeMarketplaceSourceReader; + } = {} +) { + const builtDir = `/built/${toolId}`; + const catalogPath = + toolId === "codex" + ? `${builtDir}/.agents/plugins/marketplace.json` + : `${builtDir}/.plugin/marketplace.json`; + const hostCatalogBytes = options.machine + ? '{"name":"aidd-framework","source":"aidd-owned","marker":7}' + : FOREIGN_CATALOG_BYTES; + const fs = new InMemoryFileAdapter({ + [catalogPath]: JSON.stringify({ + name: CATALOG, + version: "1.0.0", + plugins: [{ name: "aidd-dev" }], + }), + [CACHE_WITNESS]: "foreign bytes stay byte-for-byte", + [HOST_CATALOG_WITNESS]: hostCatalogBytes, + }); + const project = Manifest.create(); + project.addTool(toolId, "1.0.0", []); + project.addPlugin( + toolId, + InstalledPlugin.fromMetadata( + "aidd-dev", + "1.0.0", + { kind: "local", path: "/framework" }, + true, + "project", + CATALOG + ) + ); + if (options.projectNativePluginRefs !== undefined) { + project.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [], + pluginRefs: options.projectNativePluginRefs, + }); + } + const projectRepo = new InMemoryManifestRepository(project, PROJECT_ROOT); + const machineRepo = new InMemoryManifestRepository(options.machine ?? null); + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: CATALOG, + source: { kind: "local", path: "/framework" }, + scope: "user", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new NamedActivator({ + available: true, + conflictOnAdd: options.conflictOnAdd ?? false, + }); + const logger = new CapturingLogger(); + const hostRefs = options.hostRefs ?? new Map(); + const currentSource = options.machine + ? builtDir + : options.conflictOnAdd === true || hostRefs.has(FOREIGN_REF) + ? "/fake-home/host-catalog/aidd-framework" + : undefined; + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + logger, + new Map([[toolId, activator]]), + fakeEnsureBuiltMarketplace(() => builtDir), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + toolId as AiToolId, + options.hostReader ?? + new FakeHostPluginRegistryReader({ + location: `/fake-home/${toolId}/registry`, + refs: hostRefs, + }), + ], + ]), + machineRepo, + new Map([ + [ + toolId as AiToolId, + options.sourceReader ?? + new FakeNativeMarketplaceSourceReader( + activator, + "effective-list", + (path) => (path === builtDir ? CATALOG : undefined), + currentSource === undefined + ? new Map() + : new Map([ + [ + CATALOG, + { + kind: "effective-list", + root: currentSource, + sourceType: "local", + source: currentSource, + }, + ], + ]) + ), + ], + ]) + ); + return { useCase, fs, projectRepo, machineRepo, registry, activator, logger, hostCatalogBytes }; +} + +function canonicallyOwned(toolId: "codex" | "copilot"): Manifest { + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [ + { + alias: CATALOG, + hostName: CATALOG, + provenance: { + kind: "effective-list", + root: `/built/${toolId}`, + sourceType: "local", + source: `/built/${toolId}`, + }, + }, + ], + pluginRefs: [], + pluginClaims: [{ ref: PLUGIN_REF, dependents: [PROJECT_ROOT] }], + }); + return machine; +} + +describe.each(["codex", "copilot"] as const)("%s native marketplace provenance", (toolId) => { + it.each([ + "recovered proof", + "recovered proof with a shared host alias", + "foreign source", + "missing source", + "foreign ref", + "unreadable refs", + ] as const)( + "preserves existing projections after an initial source-read refusal followed by %s", + async (change) => { + const recovered = change.startsWith("recovered proof"); + const sharedAlias = change === "recovered proof with a shared host alias"; + const machine = canonicallyOwned(toolId); + const target = machine.getNativeRegistrations(toolId)?.marketplaces[0]; + if (target === undefined) throw new Error("fixture lacks canonical catalogue"); + const otherRef = sharedAlias ? `sibling-plugin@${CATALOG}` : "aidd-dev@other-catalog"; + const staleRef = `obsolete-plugin@${CATALOG}`; + const otherRoot = sharedAlias ? `/built/${toolId}` : "/other"; + const other = { + alias: "other-alias", + hostName: sharedAlias ? CATALOG : "other-catalog", + provenance: { + kind: "effective-list" as const, + root: otherRoot, + sourceType: "local", + source: otherRoot, + }, + }; + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [other, target], + pluginRefs: [otherRef], + pluginClaims: [ + { ref: otherRef, dependents: ["/B"] }, + { ref: PLUGIN_REF, dependents: ["/B"] }, + ], + }); + let sourceReads = 0; + const sourceReader: NativeMarketplaceSourceReader = { + read: async () => { + sourceReads += 1; + if (sourceReads === 1) + return { location: "host catalogue list", unreadable: "transient permissions error" }; + if (change === "missing source") + return { location: "host catalogue list", entries: new Map() }; + const root = change === "foreign source" ? "/foreign/catalogue" : `/built/${toolId}`; + return { + location: "host catalogue list", + entries: new Map([ + [CATALOG, { kind: "effective-list", root, sourceType: "local", source: root }], + ]), + }; + }, + }; + let registryReads = 0; + const hostReader: HostPluginRegistryReader = { + read: async () => { + registryReads += 1; + if (registryReads > 1 && change === "unreadable refs") + return { location: "host plugin registry", unreadable: "permission denied" }; + const refs = new Map([ + [PLUGIN_REF, { enabled: true }], + [otherRef, { enabled: true }], + ]); + if (registryReads > 1 && change === "foreign ref") + refs.set(FOREIGN_REF, { enabled: true }); + return { location: "host plugin registry", refs }; + }, + }; + const f = makeSync(toolId, { machine, sourceReader, hostReader }); + if (sharedAlias) { + await f.registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: other.alias, + source: { kind: "local", path: "/framework" }, + scope: "user", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + f.projectRepo + .getCurrent() + ?.addPlugin( + toolId, + InstalledPlugin.fromMetadata( + "sibling-plugin", + "1.0.0", + { kind: "local", path: "/framework" }, + true, + "project", + other.alias + ) + ); + } + f.projectRepo.getCurrent()?.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [other, target], + pluginRefs: [otherRef, PLUGIN_REF, staleRef], + }); + + const result = await f.useCase.execute({ + projectRoot: PROJECT_ROOT, + marketplaceNames: [CATALOG], + }); + + expect(result.errors).toEqual([]); + expect(result.warnings.join(" ")).toContain("transient permissions error"); + expect(f.activator.addedMarketplaces).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.activator.refreshedCatalogs).toEqual(recovered ? [CATALOG] : []); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations(toolId)).toEqual({ + binary: toolId, + marketplaces: [other, target], + pluginRefs: + recovered && !sharedAlias ? [otherRef, PLUGIN_REF] : [otherRef, PLUGIN_REF, staleRef], + }); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations(toolId)).toEqual({ + binary: toolId, + marketplaces: [other, target], + pluginRefs: [otherRef], + pluginClaims: [ + { ref: otherRef, dependents: ["/B"] }, + { + ref: PLUGIN_REF, + dependents: recovered ? ["/B", PROJECT_ROOT] : ["/B"], + }, + ], + }); + expect(f.machineRepo.saveCount).toBe(recovered ? 1 : 0); + expect(f.fs.getFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(f.fs.getFile(HOST_CATALOG_WITNESS)).toBe(f.hostCatalogBytes); + } + ); + + it("leaves a foreign same-name catalogue, ref, and cache untouched", async () => { + const fixture = makeSync(toolId, { + conflictOnAdd: true, + hostRefs: new Map([[FOREIGN_REF, { enabled: true }]]), + }); + + const result = await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.refreshedCatalogs).toEqual([]); + expect(fixture.activator.removedMarketplaces).toEqual([]); + expect(fixture.activator.addedMarketplaces).toEqual([]); + expect(fixture.activator.enabledPlugins).toEqual([]); + expect(await fixture.fs.readFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(await fixture.fs.readFile(HOST_CATALOG_WITNESS)).toBe(FOREIGN_CATALOG_BYTES); + expect(fixture.machineRepo.getCurrent()).toBeNull(); + expect(result.warnings.join("\n")).toContain(CATALOG); + }); + + it("does not even re-add a same-name catalogue carrying a foreign ref", async () => { + const fixture = makeSync(toolId, { + hostRefs: new Map([[FOREIGN_REF, { enabled: true }]]), + }); + + const result = await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.addedMarketplaces).toEqual([]); + expect(fixture.activator.refreshedCatalogs).toEqual([]); + expect(fixture.activator.enabledPlugins).toEqual([]); + expect(await fixture.fs.readFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(await fixture.fs.readFile(HOST_CATALOG_WITNESS)).toBe(FOREIGN_CATALOG_BYTES); + expect(result.warnings.join("\n")).toContain("legacy claim has no source proof"); + }); + + it("refreshes only an exact canonically owned catalogue without foreign refs", async () => { + const fixture = makeSync(toolId, { machine: canonicallyOwned(toolId) }); + + await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.refreshedCatalogs).toEqual([CATALOG]); + expect(fixture.activator.enabledPlugins).toEqual([PLUGIN_REF]); + expect(await fixture.fs.readFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(await fixture.fs.readFile(HOST_CATALOG_WITNESS)).toBe(fixture.hostCatalogBytes); + }); + + it("attaches A to the exact already-enabled machine claim without re-enabling or changing B's other claim", async () => { + const machine = canonicallyOwned(toolId); + const otherRef = "aidd-dev@other-catalog"; + const targetRegistration = { + alias: CATALOG, + hostName: CATALOG, + provenance: { + kind: "effective-list" as const, + root: `/built/${toolId}`, + sourceType: "local", + source: `/built/${toolId}`, + }, + }; + const otherRegistration = { + alias: "other-alias", + hostName: "other-catalog", + provenance: { + kind: "effective-list" as const, + root: "/other", + sourceType: "local", + source: "/other", + }, + }; + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [otherRegistration, targetRegistration], + pluginRefs: [otherRef], + pluginClaims: [ + { ref: otherRef, dependents: ["/B"] }, + { ref: PLUGIN_REF, dependents: ["/B"] }, + ], + }); + const fixture = makeSync(toolId, { + machine, + hostRefs: new Map([ + [PLUGIN_REF, { enabled: true }], + [otherRef, { enabled: true }], + ["user-plugin@external-catalog", { enabled: true }], + ]), + }); + + const result = await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.errors).toStrictEqual([]); + expect(result.warnings).toStrictEqual([]); + expect(fixture.activator.enabledPlugins).toStrictEqual([]); + expect(fixture.activator.addedMarketplaces).toStrictEqual([]); + expect(fixture.activator.removedMarketplaces).toStrictEqual([]); + expect(fixture.activator.refreshedCatalogs).toStrictEqual([CATALOG]); + expect( + fixture.projectRepo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs + ).toStrictEqual([PLUGIN_REF]); + expect(fixture.machineRepo.getCurrent()?.getNativeRegistrations(toolId)).toStrictEqual({ + binary: toolId, + marketplaces: [otherRegistration, targetRegistration], + pluginRefs: [otherRef], + pluginClaims: [ + { ref: otherRef, dependents: ["/B"] }, + { ref: PLUGIN_REF, dependents: ["/B", PROJECT_ROOT] }, + ], + }); + expect(fixture.fs.getFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(fixture.fs.getFile(HOST_CATALOG_WITNESS)).toBe(fixture.hostCatalogBytes); + }); + + it.each(["foreign source", "unreadable source", "foreign ref", "unreadable refs"] as const)( + "does not refresh or enable after the host changes to %s during sync", + async (change) => { + let sourceReads = 0; + let registryReads = 0; + const sourceReader: NativeMarketplaceSourceReader = { + read: async () => { + sourceReads += 1; + if (sourceReads > 1 && change === "unreadable source") + return { location: "host catalogue list", unreadable: "permission denied" }; + const root = + sourceReads > 1 && change === "foreign source" + ? "/someone-elses/catalogue" + : `/built/${toolId}`; + return { + location: "host catalogue list", + entries: new Map([ + [ + CATALOG, + { + kind: "effective-list" as const, + root, + sourceType: "local", + source: root, + }, + ], + ]), + }; + }, + }; + const hostReader: HostPluginRegistryReader = { + read: async () => { + registryReads += 1; + if (registryReads > 1 && change === "unreadable refs") + return { location: "host plugin registry", unreadable: "permission denied" }; + return { + location: "host plugin registry", + refs: new Map( + registryReads > 1 && change === "foreign ref" + ? [[FOREIGN_REF, { enabled: true }]] + : [] + ), + }; + }, + }; + const fixture = makeSync(toolId, { + machine: canonicallyOwned(toolId), + sourceReader, + hostReader, + }); + + await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.addedMarketplaces).toEqual([]); + expect(fixture.activator.removedMarketplaces).toEqual([]); + expect(fixture.activator.refreshedCatalogs).toEqual([]); + expect(fixture.activator.enabledPlugins).toEqual([]); + expect(fixture.machineRepo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual( + [] + ); + expect( + fixture.machineRepo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims ?? [] + ).toEqual([{ ref: PLUGIN_REF, dependents: [PROJECT_ROOT] }]); + expect(await fixture.fs.readFile(HOST_CATALOG_WITNESS)).toBe(fixture.hostCatalogBytes); + expect(await fixture.fs.readFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + } + ); + + it("refuses refresh when a foreign ref shares an AIDD-owned catalogue name", async () => { + const fixture = makeSync(toolId, { + machine: canonicallyOwned(toolId), + hostRefs: new Map([[FOREIGN_REF, { enabled: true }]]), + }); + + const result = await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.refreshedCatalogs).toEqual([]); + expect(await fixture.fs.readFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(result.warnings.join("\n")).toContain(FOREIGN_REF); + }); + + it("does not treat a project-only native ref as machine ownership", async () => { + const fixture = makeSync(toolId, { + machine: canonicallyOwned(toolId), + projectNativePluginRefs: [FOREIGN_REF], + hostRefs: new Map([[FOREIGN_REF, { enabled: true }]]), + }); + + const result = await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.refreshedCatalogs).toEqual([]); + expect(fixture.activator.addedMarketplaces).toEqual([]); + expect(fixture.activator.enabledPlugins).toEqual([]); + expect(fixture.activator.removedMarketplaces).toEqual([]); + expect(await fixture.fs.readFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(await fixture.fs.readFile(HOST_CATALOG_WITNESS)).toBe(fixture.hostCatalogBytes); + expect(result.warnings.join("\n")).toContain(FOREIGN_REF); + }); + + it("activates a freshly added AIDD ref without a redundant refresh", async () => { + const fixture = makeSync(toolId); + + await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.addedMarketplaces).toEqual([`/built/${toolId}`]); + expect(fixture.activator.enabledPlugins).toEqual([PLUGIN_REF]); + expect(fixture.activator.refreshedCatalogs).toEqual([]); + expect(fixture.machineRepo.getCurrent()?.getNativeRegistrations(toolId)?.marketplaces).toEqual([ + { + alias: CATALOG, + hostName: CATALOG, + provenance: { + kind: "effective-list", + root: `/built/${toolId}`, + sourceType: "local", + source: `/built/${toolId}`, + }, + }, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-recovery-contract.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-recovery-contract.integration.test.ts new file mode 100644 index 000000000..033b66166 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-recovery-contract.integration.test.ts @@ -0,0 +1,811 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { UserSourceReferences } from "../../../../../src/contexts/framework/domain/ports/user-source-references.js"; +import type { HostMarketplaceRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-marketplace-registry-reader.js"; +import type { HostPluginRegistryReading } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceReader, +} from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { NativePluginCliError } from "../../../../../src/kernel/errors.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { builtMarketplaceDir, userBuiltMarketplaceDir } from "../../../../../src/kernel/paths.js"; +import type { MarketplaceScope } from "../../../../../src/kernel/scope.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const ROOT = "/project"; +const ALIAS = "team-tools"; +const HOST = "team-catalog"; +const BUILT = "/built/claude"; +const REF = `review@${HOST}`; +const OLD = builtMarketplaceDir(ROOT, ALIAS, "claude"); +const ADD_ERROR = "catalogue already exists"; +const RECLAIM = `Marketplace '${HOST}' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back.`; + +class RecoveringActivator extends FakeNativePluginActivator { + readonly adds: Array<{ source: string; scope: MarketplaceScope | undefined }> = []; + readonly removals: Array<{ name: string; scope: MarketplaceScope | undefined; force: boolean }> = + []; + + constructor(private readonly failure?: "remove" | "retry" | "crash") { + super({ available: true, registrationState: "dead" }); + } + + override addMarketplace(source: string, scope?: MarketplaceScope): void { + this.adds.push({ source, scope }); + if (this.adds.length === 1) throw new NativePluginCliError(ADD_ERROR); + if (this.failure === "retry") throw new NativePluginCliError("retry denied"); + if (this.failure === "crash") throw new Error("retry crashed"); + super.addMarketplace(source, scope); + } + + override removeMarketplace( + name: string, + scope?: MarketplaceScope, + options?: { force?: boolean } + ): void { + this.removals.push({ name, scope, force: options?.force === true }); + if (this.failure === "remove") throw new NativePluginCliError("remove denied"); + super.removeMarketplace(name, scope, options); + } +} + +interface RecoveryOptions { + source?: string; + userRoot?: string; + scope?: MarketplaceScope; + failure?: "remove" | "retry" | "crash"; + state?: "live" | "unknown"; + realpathFails?: readonly string[]; + host?: HostMarketplaceRegistryReader; + plugins?: readonly HostPluginRegistryReading[]; + sourceAfterCollision?: "absent" | "foreign"; + settings?: string; + ownRefs?: readonly string[]; +} + +async function recovery(options: RecoveryOptions = {}) { + const source = options.source ?? OLD; + const scope = options.scope ?? "project"; + const manifest = Manifest.create(); + const hasher = new DeterministicHasher(); + const settings = options.settings ?? "{}"; + manifest.addTool("claude", "1.0.0", [ + new InstallationFile({ + relativePath: ".claude/settings.json", + content: settings, + hash: hasher.hash(settings), + }), + ]); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "review", + "1.0.0", + { kind: "local", path: "/plugins/review" }, + true, + "project", + ALIAS + ) + ); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: ALIAS, hostName: HOST, provenance: { kind: "registry", source } }], + pluginRefs: options.ownRefs ?? [], + }); + const repo = new InMemoryManifestRepository(manifest, ROOT); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + ROOT, + Marketplace.create({ + name: ALIAS, + source: { kind: "local", path: "/source" }, + scope, + addedAt: "2026-09-15T00:00:00Z", + }) + ); + class MissingDirectories extends InMemoryFileAdapter { + override async realpath(path: string): Promise { + if (options.realpathFails?.includes(path)) throw new Error(`ENOENT: ${path}`); + return super.realpath(path); + } + } + const fs = new MissingDirectories({ + [`${BUILT}/.claude-plugin/marketplace.json`]: JSON.stringify({ + name: HOST, + plugins: [{ name: "review" }], + }), + ...(options.settings === undefined + ? {} + : { [resolve(ROOT, ".claude/settings.json")]: options.settings }), + }); + const logger = new CapturingLogger(); + const pluginReadings = [...(options.plugins ?? [{ location: "host plugins", refs: new Map() }])]; + const activator = + options.state === undefined + ? new RecoveringActivator(options.failure) + : new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: options.state, + }); + const delegate = new FakeNativeMarketplaceSourceReader( + activator, + "registry", + () => HOST, + new Map([[HOST, { kind: "registry", source }]]) + ); + const native: NativeMarketplaceSourceReader = { + read: async (root) => { + if (activator.removedMarketplaces.length > 0 && activator.addedMarketplaces.length === 0) { + return { location: "host source after removal", entries: new Map() }; + } + if ( + activator instanceof RecoveringActivator && + activator.adds.length > 0 && + activator.addedMarketplaces.length === 0 && + options.sourceAfterCollision !== undefined + ) { + return { + location: "host source after collision", + entries: + options.sourceAfterCollision === "absent" + ? new Map() + : new Map([[HOST, { kind: "registry", source: "/foreign/source" }]]), + }; + } + return delegate.read(root); + }, + }; + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + repo, + registry, + hasher, + logger, + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + new Map([ + [ + "claude", + options.host ?? + new FakeHostMarketplaceRegistryReader({ + location: "host marketplaces", + entries: new Map([[HOST, source]]), + }), + ], + ]), + () => options.userRoot ?? "", + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + { + read: async () => { + const reading = pluginReadings[0]; + if (pluginReadings.length > 1) pluginReadings.shift(); + return reading; + }, + }, + ], + ]), + undefined, + new Map([["claude", native]]) + ); + const result = await useCase.execute({ projectRoot: ROOT }); + return { activator, manifest, repo, logger, fs, result, useCase }; +} + +describe("marketplace sync recovery preserves public ownership and failure outcomes", () => { + it.each(["project", "user"] as const)( + "reclaims an owned dead catalogue at its marketplace's %s scope", + async (scope) => { + const { activator, result, manifest, logger } = await recovery({ scope }); + expect(activator).toBeInstanceOf(RecoveringActivator); + if (!(activator instanceof RecoveringActivator)) + throw new Error("expected recovery activator"); + expect(activator.adds).toEqual([ + { source: BUILT, scope }, + { source: BUILT, scope }, + ]); + expect(activator.removals).toEqual([{ name: HOST, scope, force: true }]); + expect(activator.enabledPlugins).toEqual([REF]); + expect(result).toEqual({ + activated: ["claude"], + binaryMissing: [], + warnings: [RECLAIM], + errors: [], + }); + expect(logger.warnMessages).toEqual([RECLAIM]); + expect(manifest.getNativeRegistrations("claude")).toEqual({ + binary: "claude", + marketplaces: [ + { alias: ALIAS, hostName: HOST, provenance: { kind: "registry", source: BUILT } }, + ], + pluginRefs: [REF], + }); + } + ); + + it("recognizes the original project path even when its directory and project root no longer resolve", async () => { + const { activator, result } = await recovery({ realpathFails: [OLD, ROOT] }); + expect(activator.removedMarketplaces).toEqual([HOST]); + expect(activator.enabledPlugins).toEqual([REF]); + expect(result.warnings).toEqual([RECLAIM]); + expect(result.errors).toEqual([]); + }); + + it("recognizes an owned vanished user cache using the unresolved user root", async () => { + const userRoot = "/users/alice/aidd"; + const source = userBuiltMarketplaceDir(userRoot, "1.0.0", ALIAS, "claude"); + const { activator, result } = await recovery({ + source, + userRoot, + realpathFails: [source, userRoot], + }); + expect(activator.removedMarketplaces).toEqual([HOST]); + expect(activator.enabledPlugins).toEqual([REF]); + expect(result.warnings).toEqual([RECLAIM]); + expect(result.errors).toEqual([]); + }); + + it.each(["live", "unknown"] as const)( + "reports the add refusal for a %s registration without force-removing it", + async (state) => { + const { activator, result } = await recovery({ state }); + expect(activator.forcedRemovals).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings).toEqual([ + `Native plugin activation — register marketplace '${HOST}' skipped: marketplace is already added from a different source; remove it before adding this source`, + ]); + expect(result.errors).toEqual([]); + } + ); + + it.each([ + "/foreign/cache", + builtMarketplaceDir(ROOT, "different-marketplace", "claude"), + builtMarketplaceDir(ROOT, ALIAS, "codex"), + userBuiltMarketplaceDir("/users/alice/aidd", "1.0.0", ALIAS, "claude"), + ])( + "refuses force removal for a source outside this catalogue's recognized cache: %s", + async (source) => { + const { activator, result, manifest } = await recovery({ source }); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([REF]); + expect(result.warnings).toEqual([ + `Native plugin activation — catalogue '${HOST}' host catalogue source is unproven; host registration left untouched. ${ADD_ERROR}`, + ]); + expect(manifest.getNativeRegistrations("claude")?.marketplaces[0]?.provenance).toEqual({ + kind: "registry", + source, + }); + } + ); + + it("keeps a failed remove recoverable and records the still-owned original source", async () => { + const { activator, result, manifest, logger } = await recovery({ failure: "remove" }); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings).toEqual([ + RECLAIM, + `Native plugin activation — unregister stale marketplace '${HOST}' skipped: remove denied`, + ]); + expect(logger.warnMessages).toEqual(result.warnings); + expect(result.errors).toEqual([]); + expect(manifest.getNativeRegistrations("claude")?.marketplaces[0]?.provenance).toEqual({ + kind: "registry", + source: OLD, + }); + }); + + it("reports a failed retry separately from a successful forced remove", async () => { + const { activator, result, logger } = await recovery({ failure: "retry" }); + expect(activator.removedMarketplaces).toEqual([HOST]); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(result.warnings).toEqual([ + RECLAIM, + `Native plugin activation — register marketplace '${HOST}' skipped: retry denied`, + ]); + expect(logger.warnMessages).toEqual(result.warnings); + expect(result.errors).toEqual([]); + }); + + it("returns a retry crash as a hard tool error and preserves earlier manifest ownership", async () => { + const { activator, result, manifest } = await recovery({ failure: "crash" }); + expect(activator.removedMarketplaces).toEqual([HOST]); + expect(activator.enabledPlugins).toEqual([]); + expect(result).toEqual({ + activated: [], + binaryMissing: [], + warnings: [RECLAIM], + errors: [{ scope: "claude", message: "retry crashed" }], + }); + expect(manifest.getNativeRegistrations("claude")?.marketplaces[0]?.provenance).toEqual({ + kind: "registry", + source: OLD, + }); + }); + + it.each(["absent", "foreign"] as const)( + "does not reclaim when the source becomes %s between the failed add and recovery", + async (sourceAfterCollision) => { + const { activator, result, manifest } = await recovery({ sourceAfterCollision }); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(result.warnings).toEqual([ + `Native plugin activation — catalogue '${HOST}' collides with unproven AIDD ownership; host registration left untouched. ${ADD_ERROR}`, + ]); + expect(manifest.getNativeRegistrations("claude")?.marketplaces).toEqual([ + { alias: ALIAS, hostName: HOST, provenance: { kind: "registry", source: OLD } }, + ]); + expect(result.errors).toEqual([]); + } + ); + + it("reports the foreign plugin that appeared after add failed and leaves the host untouched", async () => { + const foreign = `other@${HOST}`; + const { activator, result } = await recovery({ + plugins: [ + { location: "host plugins", refs: new Map() }, + { location: "host plugins", refs: new Map([[foreign, { enabled: true }]]) }, + ], + }); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(result.warnings).toEqual([ + `Native plugin activation — catalogue '${HOST}' collides with unproven AIDD ownership and foreign ref '${foreign}'; host registration left untouched. ${ADD_ERROR}`, + ]); + expect(result.errors).toEqual([]); + }); + + it("refuses recovery when the host marketplace registry disappears after the failed add", async () => { + const owned = { location: "host marketplaces", entries: new Map([[HOST, OLD]]) }; + const { activator, result } = await recovery({ + host: new FakeHostMarketplaceRegistryReader(owned, owned, { + location: "host marketplaces", + absent: true, + }), + }); + expect(activator.removedMarketplaces).toEqual([]); + expect(result.warnings).toEqual([ + `Native plugin activation — catalogue '${HOST}' host catalogue source is unproven; host registration left untouched. ${ADD_ERROR}`, + ]); + expect(result.errors).toEqual([]); + }); + + it("preserves an enabled project-owned ref without a machine manifest or another install", async () => { + const { activator, result, manifest } = await recovery({ + ownRefs: [REF], + plugins: [{ location: "host plugins", refs: new Map([[REF, { enabled: true }]]) }], + }); + expect(activator.removedMarketplaces).toEqual([HOST]); + expect(activator.enabledPlugins).toEqual([]); + expect(manifest.getNativeRegistrations("claude")?.pluginRefs).toEqual([REF]); + expect(result.errors).toEqual([]); + }); + + it("does not resave the manifest after a repeated sync produces identical native ownership and settings", async () => { + const { activator, result, useCase, repo, fs, manifest } = await recovery({ + settings: JSON.stringify({ + enabledPlugins: { [`review@${ALIAS}`]: true }, + permissions: { allow: ["Read"] }, + }), + }); + expect(result.errors).toEqual([]); + const saveCount = repo.saveCount; + const second = await useCase.execute({ projectRoot: ROOT }); + expect(second).toEqual({ activated: ["claude"], binaryMissing: [], warnings: [], errors: [] }); + expect(repo.saveCount).toBe(saveCount); + expect(activator.addedMarketplaces).toEqual([BUILT]); + expect(manifest.getToolFiles("claude")[0]?.hash.value).toBe( + new DeterministicHasher().hash(await fs.readFile(resolve(ROOT, ".claude/settings.json"))) + .value + ); + }); + + it.each(["null", "[]", "false", '"user value"'])( + "rebuilds managed settings from a JSON root that is not a settings object: %s", + async (settings) => { + const { fs, manifest, result, logger } = await recovery({ settings }); + const content = await fs.readFile(resolve(ROOT, ".claude/settings.json")); + expect(JSON.parse(content)).toEqual({ enabledPlugins: { [`review@${ALIAS}`]: true } }); + expect(manifest.getToolFiles("claude")[0]?.hash.value).toBe( + new DeterministicHasher().hash(content).value + ); + expect(logger.warnMessages).toEqual([RECLAIM]); + expect(result.errors).toEqual([]); + } + ); + + it("evicts only the obsolete marketplace key from otherwise empty managed settings", async () => { + const { fs, manifest, result } = await recovery({ + settings: JSON.stringify({ + extraKnownMarketplaces: {}, + permissions: { allow: ["Read"] }, + enabledPlugins: { [`review@${ALIAS}`]: false, "personal@external": true }, + }), + }); + const content = await fs.readFile(resolve(ROOT, ".claude/settings.json")); + expect(JSON.parse(content)).toEqual({ + permissions: { allow: ["Read"] }, + enabledPlugins: { [`review@${ALIAS}`]: false, "personal@external": true }, + }); + expect(manifest.getToolFiles("claude")[0]?.hash.value).toBe( + new DeterministicHasher().hash(content).value + ); + expect(result.errors).toEqual([]); + }); +}); + +async function ownedNativeSettings( + options: { + toolId?: "codex" | "copilot"; + names?: readonly string[]; + settings?: string; + withPlugins?: boolean; + cachedRoot?: string; + cachedCatalogue?: string; + } = {} +) { + const toolId = options.toolId ?? "copilot"; + const names = options.names ?? ["first"]; + const settings = options.settings ?? JSON.stringify({ permissions: { allow: ["Read"] } }); + const settingsPath = ".github/copilot/settings.json"; + const hasher = new DeterministicHasher(); + const project = Manifest.create(); + project.addTool( + toolId, + "1.0.0", + toolId === "copilot" + ? [ + new InstallationFile({ + relativePath: settingsPath, + content: settings, + hash: hasher.hash(settings), + }), + ] + : [] + ); + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + const registrations = names.map((name) => ({ + alias: `${name}-alias`, + hostName: `${name}-catalog`, + provenance: { + kind: "effective-list" as const, + root: + name === (options.cachedCatalogue ?? names[0]) + ? (options.cachedRoot ?? `/built/${toolId}/${name}`) + : `/built/${toolId}/${name}`, + sourceType: "local", + source: `/built/${toolId}/${name}`, + }, + })); + const refs = + options.withPlugins === false + ? [] + : registrations.map( + (registration, index) => `review-${names[index]}@${registration.hostName}` + ); + project.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: registrations, + pluginRefs: refs, + }); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: registrations, + pluginRefs: [], + pluginClaims: refs.map((ref) => ({ ref, dependents: [ROOT] })), + }); + const projectRepo = new InMemoryManifestRepository(project, ROOT); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + const fs = new InMemoryFileAdapter({ [resolve(ROOT, settingsPath)]: settings }); + const identities = new Map(); + const initial = new Map(); + for (const [index, name] of names.entries()) { + const registration = registrations[index]; + const builtDir = `/built/${toolId}/${name}`; + identities.set(builtDir, registration.hostName); + initial.set(registration.hostName, registration.provenance); + fs.setFile( + `${builtDir}/${toolId === "copilot" ? ".plugin" : ".agents/plugins"}/marketplace.json`, + JSON.stringify({ name: registration.hostName, plugins: [{ name: `review-${name}` }] }) + ); + await registry.save( + ROOT, + Marketplace.create({ + name: registration.alias, + source: { kind: "local", path: `/sources/${name}` }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + if (options.withPlugins !== false) + project.addPlugin( + toolId, + InstalledPlugin.fromMetadata( + `review-${name}`, + "1.0.0", + { kind: "local", path: `/sources/${name}/review` }, + true, + "project", + registration.alias + ) + ); + } + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + hasher, + logger, + new Map([[toolId, activator]]), + { + execute: async ({ marketplace }) => ({ + builtDir: `/built/${toolId}/${marketplace.name.replace(/-alias$/, "")}`, + version: "1.0.0", + rebuilt: false, + }), + }, + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + toolId, + { + read: async () => ({ + location: "host plugins", + refs: new Map(refs.map((ref) => [ref, { enabled: true }])), + }), + }, + ], + ]), + machineRepo, + new Map([ + [ + toolId, + new FakeNativeMarketplaceSourceReader( + activator, + "effective-list", + (path) => identities.get(path), + initial + ), + ], + ]) + ); + return { + sync, + fs, + activator, + logger, + projectRepo, + machineRepo, + hasher, + settingsPath, + registrations, + refs, + }; +} + +describe("proven native catalogues update declarative settings only when required", () => { + it("projects enabled refs from two distinct owned Copilot catalogues into the tracked shared settings", async () => { + const f = await ownedNativeSettings({ names: ["first", "second"] }); + const result = await f.sync.execute({ projectRoot: ROOT }); + const content = await f.fs.readFile(resolve(ROOT, f.settingsPath)); + expect(result).toEqual({ activated: ["copilot"], binaryMissing: [], warnings: [], errors: [] }); + expect(JSON.parse(content)).toEqual({ + permissions: { allow: ["Read"] }, + enabledPlugins: { "review-first@first-catalog": true, "review-second@second-catalog": true }, + }); + expect(f.projectRepo.getCurrent()?.getToolFiles("copilot")[0]?.hash.value).toBe( + f.hasher.hash(content).value + ); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("copilot")).toEqual({ + binary: "copilot", + marketplaces: f.registrations, + pluginRefs: f.refs, + }); + expect(f.activator.addedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.projectRepo.saveCount).toBe(1); + expect(f.machineRepo.saveCount).toBe(0); + }); + + it("persists a missing declarative ref's hash even when native and machine ownership are already unchanged", async () => { + const f = await ownedNativeSettings(); + await f.sync.execute({ projectRoot: ROOT }); + const content = await f.fs.readFile(resolve(ROOT, f.settingsPath)); + expect(JSON.parse(content).enabledPlugins).toEqual({ "review-first@first-catalog": true }); + expect(f.projectRepo.getCurrent()?.getToolFiles("copilot")[0]?.hash.value).toBe( + f.hasher.hash(content).value + ); + expect(f.projectRepo.saveCount).toBe(1); + expect(f.machineRepo.saveCount).toBe(0); + await f.sync.execute({ projectRoot: ROOT }); + expect(f.projectRepo.saveCount).toBe(1); + expect(f.machineRepo.saveCount).toBe(0); + }); + + it("leaves malformed Copilot settings untouched without parsing warnings when no native refs are proven", async () => { + const settings = "{ personalSetting: true,"; + const f = await ownedNativeSettings({ withPlugins: false, settings }); + const result = await f.sync.execute({ projectRoot: ROOT }); + expect(result).toEqual({ activated: ["copilot"], binaryMissing: [], warnings: [], errors: [] }); + expect(await f.fs.readFile(resolve(ROOT, f.settingsPath))).toBe(settings); + expect(f.logger.warnMessages).toEqual([]); + expect(f.projectRepo.saveCount).toBe(0); + expect(f.machineRepo.saveCount).toBe(0); + }); + + it("re-registers an owned Codex local source whose cached root differs from its original build path", async () => { + const f = await ownedNativeSettings({ + toolId: "codex", + cachedRoot: "/host/cache/copied-marketplace", + }); + const result = await f.sync.execute({ projectRoot: ROOT }); + expect(result).toEqual({ activated: ["codex"], binaryMissing: [], warnings: [], errors: [] }); + expect(f.activator.addedMarketplaces).toEqual(["/built/codex/first"]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect( + f.projectRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces[0]?.provenance + ).toEqual({ + kind: "effective-list", + root: "/built/codex/first", + sourceType: "local", + source: "/built/codex/first", + }); + expect( + f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces[0]?.provenance + ).toEqual({ + kind: "effective-list", + root: "/built/codex/first", + sourceType: "local", + source: "/built/codex/first", + }); + expect(f.machineRepo.saveCount).toBe(1); + }); + + it("persists a second catalogue's updated source while retaining an already identical first catalogue", async () => { + const f = await ownedNativeSettings({ + toolId: "codex", + names: ["first", "second"], + cachedCatalogue: "second", + cachedRoot: "/host/cache/second-copy", + }); + const result = await f.sync.execute({ projectRoot: ROOT }); + expect(result.errors).toEqual([]); + expect(f.activator.addedMarketplaces).toEqual(["/built/codex/second"]); + const expected = f.registrations.map((registration) => ({ + ...registration, + provenance: { ...registration.provenance, root: registration.provenance.source }, + })); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toEqual( + expected + ); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toEqual( + expected + ); + expect(f.projectRepo.saveCount).toBe(1); + expect(f.machineRepo.saveCount).toBe(1); + }); +}); + +describe("shared framework references follow any successful native tool outcome", () => { + it.each(["project", "user"] as const)( + "records references according to the requested %s activation scope despite another tool's refusal", + async (scope) => { + const project = Manifest.create(); + project.addTool("claude", "1.0.0", []); + project.addTool("codex", "1.0.0", []); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/framework" }, + scope: "user", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const fs = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: "aidd-framework", + plugins: [], + }), + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "aidd-framework", + plugins: [], + }), + }); + fs.setSymlink(ROOT, "/real/project"); + const claude = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const codex = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const recorded: Array<{ version: string; projectRoot: string }> = []; + const references: UserSourceReferences = { + addReference: async (version, projectRoot) => { + recorded.push({ version, projectRoot }); + }, + removeReference: async () => undefined, + listAllReferencingProjects: async () => [], + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(project), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", claude], + ["codex", codex], + ]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + references, + { get: () => "2.0.0" }, + new Map([ + ["claude", { read: async () => ({ location: "host plugins", refs: new Map() }) }], + ["codex", { read: async () => ({ location: "host plugins", refs: new Map() }) }], + ]), + undefined, + new Map<"claude" | "codex", NativeMarketplaceSourceReader>([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + claude, + "registry", + () => "aidd-framework", + new Map() + ), + ], + [ + "codex", + { + read: async () => ({ + location: "host sources", + entries: new Map([["aidd-framework", null]]), + }), + }, + ], + ]) + ); + const result = await sync.execute({ projectRoot: ROOT, scope }); + expect(result.activated).toEqual(["claude", "codex"]); + expect(result.errors).toEqual([]); + expect(result.warnings.join(" ")).toContain("host source unproven"); + expect(claude.addedMarketplaces).toEqual(["/built/claude"]); + expect(codex.addedMarketplaces).toEqual([]); + expect(recorded).toEqual( + scope === "project" ? [{ version: "2.0.0", projectRoot: "/real/project" }] : [] + ); + } + ); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts index 5287cc472..f81091f24 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts @@ -13,6 +13,8 @@ import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -43,6 +45,22 @@ async function sync(options: { const logger = new CapturingLogger(); const manifest = Manifest.create(); manifest.addTool("claude", "test", []); + const machine = Manifest.create(); + machine.addTool("claude", "test", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: name, + hostName: name, + ...(options.registeredPath === undefined + ? {} + : { provenance: { kind: "registry" as const, source: options.registeredPath } }), + }, + ], + pluginRefs: [], + pluginClaims: [], + }); await manifestRepo.save(manifest); await registry.save( PROJECT_ROOT, @@ -80,7 +98,33 @@ async function sync(options: { new Map([["claude", activator]]), fakeEnsureBuiltMarketplace(() => builtDir), new Map([["claude", hostReader]]), - () => USER_CACHE_ROOT + () => USER_CACHE_ROOT, + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/home/.claude/plugins/installed_plugins.json", + refs: new Map(), + }), + ], + ]), + new InMemoryManifestRepository(machine), + new Map([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === builtDir ? name : undefined), + options.registeredPath === undefined + ? new Map() + : new Map([[name, { kind: "registry", source: options.registeredPath }]]) + ), + ], + ]) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT, @@ -139,7 +183,7 @@ describe("the sync write path refuses to roll a host back to an older aidd-frame registeredVersion: "1.0.0", }); - expect(activator.addedMarketplaces).toEqual([sharedPath("1.0.0")]); + expect(activator.addedMarketplaces).toEqual([]); expect(result.errors).toEqual([]); expect(result.warnings).toEqual([]); }); @@ -166,7 +210,13 @@ describe("the sync write path refuses to roll a host back to an older aidd-frame expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ binary: "claude", - marketplaces: [{ alias: MARKETPLACE_NAME, hostName: MARKETPLACE_NAME }], + marketplaces: [ + { + alias: MARKETPLACE_NAME, + hostName: MARKETPLACE_NAME, + provenance: { kind: "registry", source: sharedPath("2.0.0") }, + }, + ], pluginRefs: [], }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts index f0885355d..4f7002f24 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts @@ -27,6 +27,8 @@ import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -37,6 +39,48 @@ const CLAUDE_BUILT_DIR = "/shared/built/claude"; const CODEX_BUILT_DIR = "/shared/built/codex"; const CATALOG_RELATIVE = ".claude-plugin/marketplace.json"; +function canonicallyOwned(toolId: "claude" | "codex"): Manifest { + const machine = Manifest.create(); + machine.addTool(toolId, "test", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [{ alias: FRAMEWORK_MARKETPLACE_NAME, hostName: FRAMEWORK_MARKETPLACE_NAME }], + pluginRefs: [], + pluginClaims: [], + }); + return machine; +} + +function readableHostPlugins(toolId: "claude" | "codex") { + return new Map([ + [ + toolId, + new FakeHostPluginRegistryReader({ + location: `/home/${toolId}/plugins`, + refs: new Map(), + }), + ], + ]); +} + +function freshClaudeSource( + activator: FakeNativePluginActivator, + builtDir: string, + name = FRAMEWORK_MARKETPLACE_NAME +) { + return new Map([ + [ + "claude" as const, + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === builtDir ? name : undefined), + new Map() + ), + ], + ]); +} + function fakeVersion(value: string): VersionReader { return { get: () => value }; } @@ -145,7 +189,7 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n }); } - it("reclaims a codex registration that still names the pre-migration path, by removing then re-adding the shared source", async () => { + it("refuses force reclaim for Codex even with an AIDD claim when host source is unreadable", async () => { const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, frameworkAtUserScope()); const manifest = Manifest.create(); @@ -169,16 +213,31 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n new DeterministicHasher(), new CapturingLogger(), new Map([["codex", activator]]), - fakeEnsureBuiltMarketplace(() => CODEX_BUILT_DIR) + fakeEnsureBuiltMarketplace(() => CODEX_BUILT_DIR), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins("codex"), + new InMemoryManifestRepository(canonicallyOwned("codex")), + new Map([ + [ + "codex", + { read: async () => ({ location: "/host/codex/catalogues", unreadable: "EACCES" }) }, + ], + ]) ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(activator.removedMarketplaces).toEqual([FRAMEWORK_MARKETPLACE_NAME]); - expect(activator.addedMarketplaces).toEqual([CODEX_BUILT_DIR]); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.forcedRemovals).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings.join("\n")).toContain("EACCES"); }); - it("warns with the reclaim message naming the tool that refuses the overwrite", async () => { + it("refuses an unproven reserved-name registration without force reclaim", async () => { const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, frameworkAtUserScope()); const manifest = Manifest.create(); @@ -208,10 +267,12 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - const reclaim = - "Marketplace 'aidd-framework' is registered from a different source and codex refuses to overwrite it in place; removing and re-registering it from the shared, machine-scope build. Plugins installed from it are removed and the ones this CLI manages are put back."; - expect(result.warnings).toStrictEqual([reclaim]); - expect(logger.warnMessages).toStrictEqual([reclaim]); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings).toEqual([ + expect.stringContaining("host source reader unavailable; reconcile manually"), + ]); + expect(logger.warnMessages).toEqual(result.warnings); }); it("never reclaims an arbitrary, non-reserved marketplace name this way — only the framework's own", async () => { @@ -258,7 +319,7 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n // `isUnguardedFrameworkMarketplace` excludes a tool declaring its own marketplace registry // from the reclaim door: a registry conflict is a reported error, never a silent remove-add. - it("never reclaims the reserved framework name for a tool that declares its own marketplace registry — claude reports the conflict instead", async () => { + it("does not force reclaim a reserved name that Claude's registry points at a foreign source", async () => { const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, frameworkAtUserScope()); const manifest = Manifest.create(); @@ -274,6 +335,11 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n version: "1.0.0", plugins: [], }), + [`/foreign/cache/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [{ name: "foreign-plugin" }], + }), }); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -282,7 +348,38 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n new DeterministicHasher(), new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR) + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ + location: "/host/claude/known_marketplaces.json", + entries: new Map([[FRAMEWORK_MARKETPLACE_NAME, "/foreign/cache"]]), + }), + ], + ]), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins("claude"), + undefined, + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/host/claude/catalogues", + entries: new Map([ + [ + FRAMEWORK_MARKETPLACE_NAME, + { kind: "registry" as const, source: "/foreign/cache" }, + ], + ]), + }), + }, + ], + ]) // No `hostMarketplaceRegistries` reader for claude: `guardAgainstConflict` returns // "proceed" without reading one, so the exclusion is decided in `reclaimOrReport`. ); @@ -291,7 +388,67 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n expect(activator.removedMarketplaces).toEqual([]); expect(activator.addedMarketplaces).toEqual([]); - expect(result.warnings.some((w) => w.includes("skipped:"))).toBe(true); + expect(result.errors[0]?.message).toContain("different catalog"); + expect(result.errors[0]?.message).toContain("/foreign/cache"); + }); +}); + +describe("MarketplaceSyncSettingsUseCase — fresh Claude host provenance", () => { + async function syncWithHostRegistry(reading: { absent?: true; unreadable?: string }) { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, projectScopeEntry({ kind: "local", path: "." })); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter(catalogFixture(CLAUDE_BUILT_DIR)), + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ + location: "/home/.claude/plugins/known_marketplaces.json", + ...reading, + }), + ], + ]), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/home/.claude/plugins/installed_plugins.json", + absent: true, + }), + ], + ]), + undefined, + freshClaudeSource(activator, CLAUDE_BUILT_DIR) + ); + + return { activator, result: await useCase.execute({ projectRoot: PROJECT_ROOT }) }; + } + + it("adds a fresh catalogue when the host registry is explicitly absent", async () => { + const { activator, result } = await syncWithHostRegistry({ absent: true }); + + expect(activator.addedMarketplaces).toEqual([CLAUDE_BUILT_DIR]); + expect(result.warnings).toEqual([]); + }); + + it("does not add when the host registry exists but cannot be read", async () => { + const { activator, result } = await syncWithHostRegistry({ unreadable: "EACCES" }); + + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings.join("\n")).toContain("host registry is unreadable"); }); }); @@ -308,7 +465,7 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi ); } - it("registers the shared source without breaking, and records a reference for both this project and the one the host used to point at", async () => { + it("leaves another project's cache and both references untouched without source provenance", async () => { const foreignProjectCache = builtMarketplaceDir( "/other-project", FRAMEWORK_MARKETPLACE_NAME, @@ -358,20 +515,22 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi () => USER_CACHE_ROOT, undefined, noSourceReferences(added), - fakeVersion(CURRENT_VERSION) + fakeVersion(CURRENT_VERSION), + readableHostPlugins("claude"), + new InMemoryManifestRepository(canonicallyOwned("claude")) ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); + const before = await fs.readFile(`${foreignProjectCache}/${CATALOG_RELATIVE}`); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - // The host is repointed onto the shared build without throwing: same catalog, foreign path, - // which `guardAgainstConflict` treats as an ordinary migration. - expect(activator.addedMarketplaces).toEqual([sharedBuiltDir()]); - const roots = added.map((a) => a.projectRoot); - expect(roots).toContain(PROJECT_ROOT); - expect(roots).toContain("/other-project"); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(added).toEqual([]); + expect(await fs.readFile(`${foreignProjectCache}/${CATALOG_RELATIVE}`)).toBe(before); + expect(result.warnings.join("\n")).toContain("host source unproven"); }); - it("never records a reference for the foreign project's own root once that root no longer exists", async () => { + it("does not infer ownership or add references when the old project root is gone", async () => { const foreignProjectCache = builtMarketplaceDir( "/gone-project", FRAMEWORK_MARKETPLACE_NAME, @@ -416,15 +575,18 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi () => USER_CACHE_ROOT, undefined, noSourceReferences(added), - fakeVersion(CURRENT_VERSION) + fakeVersion(CURRENT_VERSION), + readableHostPlugins("claude"), + new InMemoryManifestRepository(canonicallyOwned("claude")) ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); + const before = await fs.readFile(`${sharedBuiltDir()}/${CATALOG_RELATIVE}`); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(activator.addedMarketplaces).toEqual([sharedBuiltDir()]); - const roots = added.map((a) => a.projectRoot); - expect(roots).toContain(PROJECT_ROOT); - expect(roots).not.toContain("/gone-project"); + expect(activator.addedMarketplaces).toEqual([]); + expect(added).toEqual([]); + expect(await fs.readFile(`${sharedBuiltDir()}/${CATALOG_RELATIVE}`)).toBe(before); + expect(result.warnings.join("\n")).toContain("host source unproven"); }); function hostOnForeignCache(): { @@ -471,7 +633,7 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi return { fs, hostReader, registry, manifest, activator }; } - it("still repoints the host when this run has nowhere to record references at all", async () => { + it("does not repoint a legacy host entry when no reference ledger is available", async () => { const { fs, hostReader, registry, manifest, activator } = hostOnForeignCache(); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -482,16 +644,23 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi new Map([["claude", activator]]), fakeEnsureBuiltMarketplace(() => sharedBuiltDir()), new Map([["claude", hostReader]]), - () => USER_CACHE_ROOT + () => USER_CACHE_ROOT, + undefined, + undefined, + undefined, + readableHostPlugins("claude"), + new InMemoryManifestRepository(canonicallyOwned("claude")) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); expect(result.errors).toStrictEqual([]); - expect(activator.addedMarketplaces).toStrictEqual([sharedBuiltDir()]); + expect(activator.addedMarketplaces).toStrictEqual([]); + expect(activator.removedMarketplaces).toStrictEqual([]); + expect(result.warnings.join("\n")).toContain("host source unproven"); }); - it("still repoints the host, recording nothing, when the version to record under is unknown", async () => { + it("does not repoint or record a legacy host entry when the version is unknown", async () => { const { fs, hostReader, registry, manifest, activator } = hostOnForeignCache(); const added: Array<{ version: string; projectRoot: string }> = []; const useCase = new MarketplaceSyncSettingsUseCase( @@ -505,13 +674,18 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi new Map([["claude", hostReader]]), () => USER_CACHE_ROOT, undefined, - noSourceReferences(added) + noSourceReferences(added), + undefined, + readableHostPlugins("claude"), + new InMemoryManifestRepository(canonicallyOwned("claude")) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); expect(result.errors).toStrictEqual([]); - expect(activator.addedMarketplaces).toStrictEqual([sharedBuiltDir()]); + expect(activator.addedMarketplaces).toStrictEqual([]); + expect(activator.removedMarketplaces).toStrictEqual([]); + expect(result.warnings.join("\n")).toContain("host source unproven"); expect(added).toStrictEqual([]); }); }); @@ -737,6 +911,7 @@ describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migr it("purges only after native activation has run against the still-present cache, never before", async () => { class OrderObservingActivator implements NativePluginActivator { readonly cacheStillPresentAtAdd: boolean[] = []; + addedSource?: string; constructor( private readonly fs: InMemoryFileAdapter, private readonly probe: string @@ -747,7 +922,8 @@ describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migr enablesPlugins(): boolean { return false; } - addMarketplace(): void { + addMarketplace(source: string): void { + this.addedSource = source; this.cacheStillPresentAtAdd.push(this.fs.has(this.probe)); } removeMarketplace(): void {} @@ -781,7 +957,31 @@ describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migr fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), new Map(), () => "", - new MarketplaceRegisterFrameworkUseCase(registry) + new MarketplaceRegisterFrameworkUseCase(registry), + undefined, + undefined, + readableHostPlugins("claude"), + undefined, + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/host/claude/catalogues", + entries: new Map( + activator.addedSource === undefined + ? [] + : [ + [ + FRAMEWORK_MARKETPLACE_NAME, + { kind: "registry" as const, source: activator.addedSource }, + ], + ] + ), + }), + }, + ], + ]) ); await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); @@ -920,19 +1120,23 @@ describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migr ...catalogFixture(CLAUDE_BUILT_DIR), }); fs.setSymlink(OLD_CACHE_DIR, "/etc/evil"); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); const useCase = new MarketplaceSyncSettingsUseCase( fs, new InMemoryManifestRepository(manifestWithClaude()), registry, new DeterministicHasher(), logger, - new Map([ - ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: false })], - ]), + new Map([["claude", activator]]), fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), new Map(), () => "", - new MarketplaceRegisterFrameworkUseCase(registry) + new MarketplaceRegisterFrameworkUseCase(registry), + undefined, + undefined, + readableHostPlugins("claude"), + undefined, + freshClaudeSource(activator, CLAUDE_BUILT_DIR) ); await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); @@ -1199,6 +1403,24 @@ describe("MarketplaceSyncSettingsUseCase + DoctorRegistrationUseCase — the ful version: CURRENT_VERSION, plugins: [], }), + [`${preMigrationCache}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const machine = canonicallyOwned("claude"); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: FRAMEWORK_MARKETPLACE_NAME, + hostName: FRAMEWORK_MARKETPLACE_NAME, + provenance: { kind: "registry", source: preMigrationCache }, + }, + ], + pluginRefs: [], + pluginClaims: [], }); const sync = new MarketplaceSyncSettingsUseCase( syncFs, @@ -1212,7 +1434,25 @@ describe("MarketplaceSyncSettingsUseCase + DoctorRegistrationUseCase — the ful () => USER_CACHE_ROOT, new MarketplaceRegisterFrameworkUseCase(registry), undefined, - fakeVersion(CURRENT_VERSION) + fakeVersion(CURRENT_VERSION), + readableHostPlugins("claude"), + new InMemoryManifestRepository(machine), + new Map([ + [ + "claude", + { + read: async () => ({ + location: REGISTRY_LOCATION, + entries: new Map( + [...hostEntries].map(([name, source]) => [ + name, + { kind: "registry" as const, source }, + ]) + ), + }), + }, + ], + ]) ); const syncResult = await sync.execute({ diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts index e715a0b63..361847663 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; @@ -9,6 +9,8 @@ import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { FakeCurrentVersion } from "../../../../helpers/ports/fake-current-version.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -42,11 +44,130 @@ function seededBuiltCatalog(): InMemoryFileAdapter { }); } +function freshHostProof(activator: FakeNativePluginActivator) { + return { + plugins: new Map([ + [ + "claude" as const, + new FakeHostPluginRegistryReader({ + location: "/host/installed_plugins.json", + refs: new Map(), + }), + ], + ]), + sources: new Map([ + [ + "claude" as const, + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === "/built/claude" ? MARKETPLACE : undefined), + new Map() + ), + ], + ]), + }; +} + +describe("settings synchronization under the machine ownership lock", () => { + async function fixture() { + const projectRepo = manifestWithTool(); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplace()); + const fs = seededBuiltCatalog(); + const activator = new FakeNativePluginActivator({ available: true }); + const proof = freshHostProof(activator); + let locked = false; + const accesses: boolean[] = []; + const originalLoad = projectRepo.load.bind(projectRepo); + const load = vi.spyOn(projectRepo, "load"); + load.mockImplementation(async () => { + accesses.push(locked); + return originalLoad(); + }); + const acquireLock = vi.fn(() => {}); + const withExclusiveAccess = async (action: () => Promise): Promise => { + acquireLock(); + locked = true; + try { + return await action(); + } finally { + locked = false; + } + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + proof.plugins, + { + path: machineRepo.path, + load: () => machineRepo.load(), + save: (manifest) => machineRepo.save(manifest), + delete: () => machineRepo.delete(), + withExclusiveAccess, + }, + proof.sources + ); + return { sync, fs, activator, projectRepo, machineRepo, acquireLock, accesses, load }; + } + + it.each([undefined, "project"] as const)( + "reads project ownership only inside the machine lock for scope %s", + async (scope) => { + const f = await fixture(); + const result = await f.sync.execute({ projectRoot: PROJECT_ROOT, scope }); + expect(result.errors).toEqual([]); + expect(f.acquireLock).toHaveBeenCalledTimes(1); + expect(f.accesses.length).toBeGreaterThan(0); + expect(f.accesses.every(Boolean)).toBe(true); + expect(f.activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(f.machineRepo.saveCount).toBe(1); + } + ); + + it("does not reacquire the project ownership lock during an explicit user-scope sync", async () => { + const f = await fixture(); + const result = await f.sync.execute({ projectRoot: PROJECT_ROOT, scope: "user" }); + expect(result.errors).toEqual([]); + expect(f.acquireLock).not.toHaveBeenCalled(); + expect(f.activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(f.machineRepo.saveCount).toBe(1); + }); + + it("propagates a machine lock failure before reading or changing project and host state", async () => { + const f = await fixture(); + const error = new Error("machine ownership lock unavailable"); + f.acquireLock.mockImplementationOnce(() => { + throw error; + }); + await expect(f.sync.execute({ projectRoot: PROJECT_ROOT })).rejects.toBe(error); + expect(f.load).not.toHaveBeenCalled(); + expect(f.projectRepo.saveCount).toBe(0); + expect(f.machineRepo.saveCount).toBe(0); + expect(f.activator.addedMarketplaces).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.fs.getFile(`${PROJECT_ROOT}/.claude/settings.json`)).toBeUndefined(); + }); +}); + describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks for", () => { it("enables at project scope by default, never claude's own implicit default", async () => { const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, marketplace()); + const proof = freshHostProof(activator); const useCase = new MarketplaceSyncSettingsUseCase( seededBuiltCatalog(), manifestWithTool(), @@ -54,7 +175,15 @@ describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks new DeterministicHasher(), new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + proof.plugins, + undefined, + proof.sources ); await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -91,6 +220,7 @@ describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks plugins: [{ name: "aidd-telemetry" }], }), }); + const proof = freshHostProof(activator); const useCase = new MarketplaceSyncSettingsUseCase( catalog, new InMemoryManifestRepository(manifest), @@ -98,7 +228,15 @@ describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks new DeterministicHasher(), new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + proof.plugins, + undefined, + proof.sources ); await useCase.execute({ projectRoot: PROJECT_ROOT, scope: "user" }); @@ -244,6 +382,7 @@ describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks it("still records a shared-source reference at project scope, unaffected", async () => { const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const proof = freshHostProof(activator); const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, marketplace()); const added: Array<{ version: string; projectRoot: string }> = []; @@ -266,7 +405,10 @@ describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks () => "", undefined, userSourceReferences, - new FakeCurrentVersion() + new FakeCurrentVersion(), + proof.plugins, + undefined, + proof.sources ); await useCase.execute({ projectRoot: PROJECT_ROOT }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts index 88266e683..368cee022 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts @@ -9,9 +9,12 @@ import type { NativeRegistrations } from "../../../../../src/contexts/framework/ import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { builtMarketplaceDir } from "../../../../../src/kernel/paths.js"; import type { ToolId } from "../../../../../src/kernel/tool.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -42,6 +45,8 @@ interface Setup { readonly settingsOnDisk?: string; readonly otherTrackedFiles?: readonly string[]; readonly existingRegistrations?: NativeRegistrations; + readonly existingMachineRegistrations?: NativeRegistrations; + readonly hostRegisteredSource?: string; } function catalogPath(marketplace: string, target: string): string { @@ -124,6 +129,12 @@ async function build(setup: Setup = {}) { } const activator = setup.activator ?? new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const machineRegistrations = setup.existingMachineRegistrations; + const machine = machineRegistrations === undefined ? undefined : Manifest.create(); + if (machine !== undefined && machineRegistrations !== undefined) { + machine.addTool("claude", "test", []); + machine.setNativeRegistrations("claude", machineRegistrations); + } const useCase = new MarketplaceSyncSettingsUseCase( fs, manifestRepo, @@ -131,7 +142,47 @@ async function build(setup: Setup = {}) { hasher, new CapturingLogger(), new Map(toolIds.map((toolId) => [toolId, activator])), - buildPerMarketplace(setup.failingBuilds ?? []) + buildPerMarketplace(setup.failingBuilds ?? []), + setup.hostRegisteredSource === undefined + ? new Map() + : new Map([ + [ + "claude", + { + read: async () => ({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([[MARKETPLACE, setup.hostRegisteredSource]]), + }), + }, + ], + ]), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/home/.claude/plugins/installed_plugins.json", + refs: new Map(), + }), + ], + ]), + machine === undefined ? undefined : new InMemoryManifestRepository(machine), + new Map([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => names.find((name) => path === `/built/${name}/claude`), + setup.hostRegisteredSource === undefined + ? new Map() + : new Map([[MARKETPLACE, { kind: "registry", source: setup.hostRegisteredSource }]]) + ), + ], + ]) ); return { useCase, manifestRepo, manifest, fs, hasher }; } @@ -147,10 +198,31 @@ function trackedSettingsHash(manifest: Manifest): string | undefined { const FRAMEWORK_ONLY: NativeRegistrations = { binary: "claude", - marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { kind: "registry", source: `/built/${MARKETPLACE}/claude` }, + }, + ], pluginRefs: [], }; +function ownedMachineEntry(alias: string, hostName: string, source?: string): NativeRegistrations { + return { + binary: "claude", + marketplaces: [ + { + alias, + hostName, + ...(source === undefined ? {} : { provenance: { kind: "registry" as const, source } }), + }, + ], + pluginRefs: [], + pluginClaims: [], + }; +} + describe("how many times the manifest is written back", () => { it("zero times when a run changed nothing, writing no project file either", async () => { const activator = new FakeNativePluginActivator({ available: false }); @@ -329,28 +401,120 @@ describe("when a recorded registration is replaced", () => { expect(recorded(manifest)).toStrictEqual({ binary: "claude", marketplaces: [ - { alias: "market-a", hostName: "market-a" }, - { alias: "market-b", hostName: "market-b" }, + { + alias: "market-a", + hostName: "market-a", + provenance: { kind: "registry", source: "/built/market-a/claude" }, + }, + { + alias: "market-b", + hostName: "market-b", + provenance: { kind: "registry", source: "/built/market-b/claude" }, + }, ], pluginRefs: [], }); }); - it("records a marketplace whose build failed under its own alias", async () => { + it("records no marketplace whose build failed, this run having registered nothing for it", async () => { const { useCase, manifest } = await build({ marketplaceNames: [MARKETPLACE, "broken"], failingBuilds: ["broken"], + existingRegistrations: { + binary: "claude", + marketplaces: [{ alias: "retired", hostName: "retired-catalog" }], + pluginRefs: [], + }, }); await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(recorded(manifest)).toStrictEqual({ - binary: "claude", - marketplaces: [ - { alias: MARKETPLACE, hostName: MARKETPLACE }, - { alias: "broken", hostName: "broken" }, - ], - pluginRefs: [], + expect(recorded(manifest)?.marketplaces).toStrictEqual(FRAMEWORK_ONLY.marketplaces); + }); + + it("keeps a marketplace an earlier run registered when its build now fails", async () => { + const { useCase, manifest } = await build({ + marketplaceNames: [MARKETPLACE, "broken"], + failingBuilds: ["broken"], + existingMachineRegistrations: ownedMachineEntry("broken", "broken-catalog"), + existingRegistrations: { + binary: "claude", + marketplaces: [{ alias: "broken", hostName: "broken-catalog" }], + pluginRefs: [], + }, }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)?.marketplaces).toStrictEqual([ + ...FRAMEWORK_ONLY.marketplaces, + { alias: "broken", hostName: "broken-catalog" }, + ]); + }); +}); + +describe("a marketplace the host refused is not this project's to remove", () => { + function refusingHost(registrationState: "live" | "dead"): FakeNativePluginActivator { + return new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState, + }); + } + + it("records no marketplace the host kept under another registration", async () => { + const { useCase, manifest } = await build({ activator: refusingHost("live") }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)?.marketplaces).toStrictEqual([]); + }); + + it("keeps a marketplace an earlier run registered when the host now refuses it", async () => { + const { useCase, manifest } = await build({ + activator: refusingHost("live"), + existingMachineRegistrations: ownedMachineEntry(MARKETPLACE, MARKETPLACE), + existingRegistrations: { + binary: "claude", + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [], + }, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)?.marketplaces).toStrictEqual([ + { alias: MARKETPLACE, hostName: MARKETPLACE }, + ]); + }); + + it("records no marketplace it failed to take back from a dead registration", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "dead", + throwOnRemove: true, + }); + const { useCase, manifest } = await build({ activator }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)?.marketplaces).toStrictEqual([]); + }); + + it("records a marketplace this run took back from a dead registration", async () => { + const { useCase, manifest } = await build({ + activator: refusingHost("dead"), + existingMachineRegistrations: ownedMachineEntry( + MARKETPLACE, + MARKETPLACE, + builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude") + ), + hostRegisteredSource: builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude"), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)?.marketplaces).toStrictEqual(FRAMEWORK_ONLY.marketplaces); }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts index 01a9879cb..0b9ceed2f 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts @@ -14,6 +14,8 @@ import type { PluginSource } from "../../../../../src/kernel/source.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -47,6 +49,8 @@ interface SyncSetup { /** Refs that fail to enable — a recoverable, best-effort `NativePluginCliError`. */ readonly failOnPlugins?: readonly string[]; readonly activator?: FakeNativePluginActivator; + /** Explicitly prove the catalogue is already AIDD-owned before testing exact host refresh. */ + readonly ownedCatalog?: true; readonly marketplaceSource?: (name: string) => PluginSource; } @@ -72,9 +76,41 @@ function seededBuiltCatalog(name = "aidd-framework"): InMemoryFileAdapter { }); } +function readableClaudePluginRegistry() { + return new Map([ + [ + "claude" as const, + new FakeHostPluginRegistryReader({ + location: "/home/.claude/plugins/installed_plugins.json", + refs: new Map(), + }), + ], + ]); +} + +function freshClaudeSource(activator: FakeNativePluginActivator, name = "aidd-framework") { + return new Map([ + [ + "claude" as const, + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === "/built/claude" ? name : undefined), + new Map() + ), + ], + ]); +} + async function sync(setup: SyncSetup = {}) { const names = setup.marketplaceNames ?? ["aidd-framework"]; const fs = seededBuiltCatalog(names[0]); + for (const name of names.slice(1)) { + await fs.writeFile( + `/built/claude-${name}/.claude-plugin/marketplace.json`, + JSON.stringify({ name, version: "1.0.0", plugins: [] }) + ); + } const manifestRepo = new InMemoryManifestRepository(); const registry = new InMemoryMarketplaceRegistry(); const logger = new CapturingLogger(); @@ -111,6 +147,22 @@ async function sync(setup: SyncSetup = {}) { crashOnAddMarketplace: setup.crashOnAddMarketplace ?? false, failOnPlugins: setup.failOnPlugins ?? [], }); + const machine = setup.ownedCatalog ? Manifest.create() : undefined; + if (machine !== undefined) { + machine.addTool("claude", "test", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: names[0], + hostName: names[0], + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], + pluginRefs: [], + pluginClaims: [], + }); + } const useCase = new MarketplaceSyncSettingsUseCase( fs, manifestRepo, @@ -118,7 +170,39 @@ async function sync(setup: SyncSetup = {}) { new DeterministicHasher(), logger, new Map([["claude", activator]]), - setup.ensureBuilt ?? fakeEnsureBuiltMarketplace() + setup.ensureBuilt ?? { + execute: async ({ marketplace, target }) => ({ + builtDir: + marketplace.name === names[0] + ? `/built/${target}` + : `/built/${target}-${marketplace.name}`, + version: "test", + rebuilt: true, + }), + }, + new Map(), + () => "", + undefined, + undefined, + undefined, + readableClaudePluginRegistry(), + machine === undefined ? undefined : new InMemoryManifestRepository(machine), + new Map([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => + path === "/built/claude" + ? names[0] + : names.slice(1).find((name) => path === `/built/claude-${name}`), + setup.ownedCatalog + ? new Map([[names[0], { kind: "registry", source: "/built/claude" }]]) + : new Map() + ), + ], + ]) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); const written = (await fs.fileExists(SHARED_SETTINGS)) @@ -340,7 +424,15 @@ describe("toolIds narrows which tool's CLI is driven", () => { ["claude", claudeActivator], ["codex", codexActivator], ]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableClaudePluginRegistry(), + undefined, + freshClaudeSource(claudeActivator) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT, toolIds: ["claude"] }); @@ -381,7 +473,15 @@ describe("toolIds narrows which tool's CLI is driven", () => { ["claude", claudeActivator], ["codex", codexActivator], ]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableClaudePluginRegistry(), + undefined, + freshClaudeSource(claudeActivator) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT, toolIds: ["claude"] }); @@ -421,10 +521,10 @@ describe("what a step that could not complete leaves in warnings and errors", () it("warns about a refused marketplace upgrade and still enables the plugin", async () => { const activator = new ActivatorFailingAtUpgrade(new NativePluginCliError("registry locked")); - const { result } = await sync({ activator }); + const { result } = await sync({ activator, ownedCatalog: true }); expect(result.warnings).toStrictEqual([ - "Native plugin activation — upgrade marketplaces skipped: registry locked", + "Native plugin activation — upgrade marketplace 'aidd-framework' skipped: registry locked", ]); expect(result.errors).toStrictEqual([]); expect(activator.enabledPlugins).toStrictEqual(["aidd-context@aidd-framework"]); @@ -433,7 +533,7 @@ describe("what a step that could not complete leaves in warnings and errors", () it("reports an activator bug during the upgrade as an error, never as a warning", async () => { const activator = new ActivatorFailingAtUpgrade(new Error("activator bug")); - const { result } = await sync({ activator }); + const { result } = await sync({ activator, ownedCatalog: true }); expect(result.errors).toStrictEqual([{ scope: "claude", message: "activator bug" }]); expect(result.warnings).toStrictEqual([]); @@ -498,7 +598,15 @@ describe("a project whose manifest also lists a tool with no plugin system", () new DeterministicHasher(), new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableClaudePluginRegistry(), + undefined, + freshClaudeSource(activator) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts index ae1b02f85..a2379b446 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts @@ -16,6 +16,8 @@ import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { FakeCurrentVersion } from "../../../../helpers/ports/fake-current-version.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -24,8 +26,7 @@ import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory const PROJECT_ROOT = "/test-project"; const VERSION = "1.0.0"; -/** A manifest with claude installed and no plugin — enough for `sync` to run its - * settings pass, without a registered activator to drive (no catalog fixture needed). */ +/** A manifest with Claude installed and no plugin, for both registry and activation checks. */ async function manifestRepoWithClaudeInstalled(): Promise { const manifestRepo = new InMemoryManifestRepository(); const manifest = Manifest.create(); @@ -34,6 +35,35 @@ async function manifestRepoWithClaudeInstalled(): Promise (path === "/built/claude" ? FRAMEWORK_MARKETPLACE_NAME : undefined), + new Map() + ), + ], + ]), + }; +} + function frameworkMarketplace(): Marketplace { return Marketplace.create({ name: FRAMEWORK_MARKETPLACE_NAME, @@ -47,6 +77,7 @@ describe("the shared source's own reference, recorded by sync", () => { it("records this project's reference when the framework marketplace is already registered", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); fs.setFile(`${PROJECT_ROOT}/marker`, ""); + seedFrameworkCatalog(fs); const manifestRepo = await manifestRepoWithClaudeInstalled(); const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, frameworkMarketplace()); @@ -54,6 +85,8 @@ describe("the shared source's own reference, recorded by sync", () => { fs, () => "/fake-home/.config/aidd" ); + const activator = new FakeNativePluginActivator({ available: true }); + const proof = freshHostProof(activator); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -61,13 +94,16 @@ describe("the shared source's own reference, recorded by sync", () => { registry, new DeterministicHasher(), new CapturingLogger(), - new Map(), // no activators: this run only proves the reference, not native activation + new Map([["claude", activator]]), fakeEnsureBuiltMarketplace(), new Map(), () => "", undefined, userSourceReferences, - new FakeCurrentVersion(VERSION) + new FakeCurrentVersion(VERSION), + proof.pluginRegistries, + undefined, + proof.sources ); await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -81,6 +117,7 @@ describe("the shared source's own reference, recorded by sync", () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); fs.setFile(`${PROJECT_ROOT}/marker`, ""); fs.setFile("/fake-home/.config/aidd/references.json", "not json"); + seedFrameworkCatalog(fs); const manifestRepo = await manifestRepoWithClaudeInstalled(); const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, frameworkMarketplace()); @@ -89,6 +126,8 @@ describe("the shared source's own reference, recorded by sync", () => { () => "/fake-home/.config/aidd" ); const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const proof = freshHostProof(activator); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -96,13 +135,16 @@ describe("the shared source's own reference, recorded by sync", () => { registry, new DeterministicHasher(), logger, - new Map(), + new Map([["claude", activator]]), fakeEnsureBuiltMarketplace(), new Map(), () => "", undefined, userSourceReferences, - new FakeCurrentVersion(VERSION) + new FakeCurrentVersion(VERSION), + proof.pluginRegistries, + undefined, + proof.sources ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -155,6 +197,7 @@ describe("the shared source's own reference, recorded by sync", () => { it("recreates the framework marketplace when this machine's registry holds nothing at all", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); fs.setFile(`${PROJECT_ROOT}/marker`, ""); + seedFrameworkCatalog(fs); const manifestRepo = await manifestRepoWithClaudeInstalled(); const registry = new InMemoryMarketplaceRegistry(); // empty: nothing registered yet const userSourceReferences = new UserSourceReferencesAdapter( @@ -169,6 +212,8 @@ describe("the shared source's own reference, recorded by sync", () => { return { registered: true, scope: "user" as const }; }, }; + const activator = new FakeNativePluginActivator({ available: true }); + const proof = freshHostProof(activator); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -176,13 +221,16 @@ describe("the shared source's own reference, recorded by sync", () => { registry, new DeterministicHasher(), new CapturingLogger(), - new Map(), + new Map([["claude", activator]]), fakeEnsureBuiltMarketplace(), new Map(), () => "", registerFramework, userSourceReferences, - new FakeCurrentVersion(VERSION) + new FakeCurrentVersion(VERSION), + proof.pluginRegistries, + undefined, + proof.sources ); await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); @@ -190,6 +238,8 @@ describe("the shared source's own reference, recorded by sync", () => { expect((await registry.list(PROJECT_ROOT)).map((m) => m.name)).toContain( FRAMEWORK_MARKETPLACE_NAME ); + expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + await useCase.execute({ projectRoot: PROJECT_ROOT }); expect(await userSourceReferences.listAllReferencingProjects()).toContain(PROJECT_ROOT); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts new file mode 100644 index 000000000..6b0a0a9d2 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts @@ -0,0 +1,808 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { HostMarketplaceRegistryReading } from "../../../../../src/contexts/tools/domain/ports/host-marketplace-registry-reader.js"; +import type { HostPluginRegistryReading } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +describe("native catalogue source provenance", () => { + async function hostPluginProofFixture( + reading?: HostPluginRegistryReading, + options: { + toolId?: "claude" | "codex"; + catalogue?: HostMarketplaceRegistryReading; + marketplaceScope?: "project" | "user"; + plugin?: boolean; + } = {} + ) { + const toolId = options.toolId ?? "codex"; + const builtDir = `/built/${toolId}`; + const project = Manifest.create(); + project.addTool(toolId, "1.0.0", []); + if (options.plugin !== false) + project.addPlugin( + toolId, + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + "alias" + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: "alias", + source: { kind: "local", path: "/source" }, + scope: options.marketplaceScope ?? "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const fs = new InMemoryFileAdapter({ + [`${builtDir}/${toolId === "codex" ? ".agents/plugins" : ".claude-plugin"}/marketplace.json`]: + JSON.stringify({ + name: "same-name", + plugins: [{ name: "test-plugin" }], + }), + }); + fs.setFile("/foreign/cache/marker.json", "foreign bytes"); + const activator = new FakeNativePluginActivator({ available: true }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([[toolId, activator]]), + fakeEnsureBuiltMarketplace(), + options.catalogue === undefined + ? new Map() + : new Map([[toolId, new FakeHostMarketplaceRegistryReader(options.catalogue)]]), + () => "", + undefined, + undefined, + undefined, + reading === undefined + ? new Map() + : new Map([[toolId, new FakeHostPluginRegistryReader(reading)]]), + machineRepo, + new Map([ + [ + toolId, + new FakeNativeMarketplaceSourceReader( + activator, + toolId === "codex" ? "effective-list" : "registry", + (path) => (path === builtDir ? "same-name" : undefined), + new Map() + ), + ], + ]) + ); + return { sync, fs, activator, projectRepo, machineRepo }; + } + + it.each(["project", "user"] as const)( + "refuses a %s-scope Claude catalogue if its registry becomes unreadable after the source check", + async (marketplaceScope) => { + const f = await hostPluginProofFixture( + { location: "/host/plugins.json", refs: new Map() }, + { + toolId: "claude", + marketplaceScope, + catalogue: { location: "/host/catalogues.json", unreadable: "permission denied" }, + } + ); + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.errors).toEqual([]); + expect(result.warnings.join(" ")).toMatch(/same-name.*host registry is unreadable/); + expect(f.activator.addedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("claude")).toBeUndefined(); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("claude")?.marketplaces).toEqual( + [] + ); + expect(f.machineRepo.saveCount).toBe(0); + expect(f.fs.getFile("/foreign/cache/marker.json")).toBe("foreign bytes"); + } + ); + + it.each(["project", "user"] as const)( + "refuses a newly appearing %s-scope Claude catalogue without a canonical claim even with identical plugin names", + async (marketplaceScope) => { + const f = await hostPluginProofFixture( + { location: "/host/plugins.json", refs: new Map() }, + { + toolId: "claude", + marketplaceScope, + catalogue: { + location: "/host/catalogues.json", + entries: new Map([["same-name", "/foreign/claude"]]), + }, + } + ); + const foreignCatalog = JSON.stringify({ + name: "same-name", + plugins: [{ name: "test-plugin" }], + }); + f.fs.setFile("/foreign/claude/.claude-plugin/marketplace.json", foreignCatalog); + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.errors).toEqual([]); + expect(result.warnings.join(" ")).toContain("without a canonical AIDD claim"); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("claude")).toMatchObject({ + marketplaces: [], + pluginRefs: [], + }); + expect(f.activator.addedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("claude")).toBeUndefined(); + expect(f.machineRepo.saveCount).toBe(0); + expect(f.fs.getFile("/foreign/claude/.claude-plugin/marketplace.json")).toBe(foreignCatalog); + } + ); + + it.each([ + { + toolId: "claude", + marketplaceScope: "user", + machineCatalogue: true, + otherHost: "other-catalog", + }, + { + toolId: "claude", + marketplaceScope: "project", + machineCatalogue: false, + otherHost: "other-catalog", + }, + { + toolId: "codex", + marketplaceScope: "user", + machineCatalogue: true, + otherHost: "other-catalog", + }, + { + toolId: "codex", + marketplaceScope: "project", + machineCatalogue: true, + otherHost: "other-catalog", + }, + { toolId: "claude", marketplaceScope: "user", machineCatalogue: true, otherHost: "same-name" }, + { + toolId: "codex", + marketplaceScope: "project", + machineCatalogue: true, + otherHost: "same-name", + }, + ] as const)( + "owns only the catalogue, not undeclared plugins, and is idempotent for $toolId / $marketplaceScope / $otherHost", + async ({ toolId, marketplaceScope, machineCatalogue, otherHost }) => { + const f = await hostPluginProofFixture( + { location: "/host/plugins.json", refs: new Map() }, + { toolId, marketplaceScope, plugin: false } + ); + const machine = f.machineRepo.getCurrent(); + if (machine === null) throw new Error("fixture lacks machine manifest"); + const target = { + alias: "alias", + hostName: "same-name", + provenance: + toolId === "claude" + ? { kind: "registry" as const, source: "/built/claude" } + : { + kind: "effective-list" as const, + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }, + }; + const other = { + alias: "other-alias", + hostName: otherHost, + provenance: + otherHost === target.hostName + ? target.provenance + : toolId === "claude" + ? { kind: "registry" as const, source: "/other/source" } + : { + kind: "effective-list" as const, + root: "/other/source", + sourceType: "local", + source: "/other/source", + }, + }; + const otherRef = `other-plugin@${otherHost}`; + machine.addTool(toolId, "0.9.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [other], + pluginRefs: [otherRef], + pluginClaims: [{ ref: otherRef, dependents: ["/B"] }], + }); + + for (let run = 0; run < 2; run += 1) { + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations(toolId)).toEqual({ + binary: toolId, + marketplaces: [target], + pluginRefs: [], + }); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations(toolId)).toEqual({ + binary: toolId, + marketplaces: machineCatalogue ? [other, target] : [other], + pluginRefs: [otherRef], + pluginClaims: [{ ref: otherRef, dependents: ["/B"] }], + }); + expect(f.machineRepo.saveCount).toBe(machineCatalogue ? 1 : 0); + expect(f.machineRepo.getCurrent()?.getToolVersion(toolId)).toBe("0.9.0"); + } + expect(f.activator.addedMarketplaces).toEqual([`/built/${toolId}`]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.activator.upgradeCount).toBe(1); + expect(f.fs.getFile("/foreign/cache/marker.json")).toBe("foreign bytes"); + } + ); + + it.each(["claude", "codex"] as const)( + "initializes a fresh %s machine tool record for a catalogue without claiming its available plugins", + async (toolId) => { + const f = await hostPluginProofFixture( + { location: "/host/plugins.json", refs: new Map() }, + { toolId, marketplaceScope: "user", plugin: false } + ); + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(f.machineRepo.getCurrent()?.hasTool(toolId)).toBe(true); + expect(f.machineRepo.getCurrent()?.getToolVersion(toolId)).toBe("1.0.0"); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations(toolId)).toMatchObject({ + binary: toolId, + marketplaces: [{ alias: "alias", hostName: "same-name" }], + pluginRefs: [], + pluginClaims: [], + }); + expect(f.machineRepo.saveCount).toBe(1); + expect(f.activator.enabledPlugins).toEqual([]); + } + ); + + it("allows fresh Claude registration when the catalogue registry is explicitly absent", async () => { + const f = await hostPluginProofFixture( + { location: "/host/plugins.json", absent: true }, + { toolId: "claude", catalogue: { location: "/host/catalogues.json", absent: true } } + ); + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(f.activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(f.activator.enabledPlugins).toEqual(["test-plugin@same-name"]); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("claude")?.marketplaces).toEqual([ + { + alias: "alias", + hostName: "same-name", + provenance: { kind: "registry", source: "/built/claude" }, + }, + ]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("claude")).toBeUndefined(); + }); + + it.each([ + { label: "missing registry reader", reading: undefined, reason: "undefined" }, + { + label: "unreadable registry with a diagnostic", + reading: { location: "/host/plugins.json", unreadable: "permission denied" }, + reason: "permission denied", + }, + { + label: "unreadable registry without a diagnostic", + reading: { location: "/host/plugins.json" }, + reason: "/host/plugins.json", + }, + ])("refuses fresh Codex activation with $label", async ({ reading, reason }) => { + const f = await hostPluginProofFixture(reading); + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.warnings.join(" ")).toContain("unreadable host plugin registry"); + expect(result.warnings.join(" ")).toContain(reason); + expect(f.activator.addedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.activator.upgradeCount).toBe(0); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")).toMatchObject({ + marketplaces: [], + pluginRefs: [], + }); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")).toBeUndefined(); + expect(f.machineRepo.saveCount).toBe(0); + expect(f.fs.getFile("/foreign/cache/marker.json")).toBe("foreign bytes"); + }); + + it("accepts an explicitly absent host plugin registry only after proving the newly added source", async () => { + const f = await hostPluginProofFixture({ location: "/host/plugins.json", absent: true }); + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(f.activator.addedMarketplaces).toEqual(["/built/codex"]); + expect(f.activator.enabledPlugins).toEqual(["test-plugin@same-name"]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: "test-plugin@same-name", dependents: ["/A"] }, + ]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toEqual([ + { + alias: "alias", + hostName: "same-name", + provenance: { + kind: "effective-list", + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }, + }, + ]); + }); + + it.each([ + "foreign source", + "foreign root", + "non-local source type", + "unreadable", + "missing catalogue", + "unproven catalogue", + ] as const)( + "keeps foreign bytes and records no claim or enablement when post-add Codex proof is %s", + async (proof) => { + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + "alias" + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: "alias", + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "same-name", + plugins: [{ name: "test-plugin" }], + }), + }); + fs.setFile("/foreign/cache/marker.json", "foreign bytes"); + const activator = new FakeNativePluginActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => { + if (activator.addedMarketplaces.length === 0) + return { location: "host", entries: new Map() }; + if (proof === "unreadable") + return { location: "host", unreadable: "malformed source listing" }; + if (proof === "missing catalogue") return { location: "host", entries: new Map() }; + return { + location: "host", + entries: new Map([ + [ + "same-name", + proof === "unproven catalogue" + ? null + : { + kind: "effective-list", + root: + proof === "foreign root" || proof === "foreign source" + ? "/foreign" + : "/built/codex", + sourceType: proof === "non-local source type" ? "github" : "local", + source: proof === "foreign source" ? "/foreign" : "/built/codex", + }, + ], + ]), + }; + }, + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + "codex", + new FakeHostPluginRegistryReader({ location: "host-plugins", refs: new Map() }), + ], + ]), + machineRepo, + new Map([["codex", source]]) + ); + const result = await sync.execute({ projectRoot: "/A" }); + expect(result.warnings.join(" ")).toMatch( + /post-add|add returned.*effective host source.*reconcile manually/i + ); + expect(activator.addedMarketplaces).toEqual(["/built/codex"]); + expect(activator.enabledPlugins).toEqual([]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")).toBeUndefined(); + expect( + projectRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces ?? [] + ).toStrictEqual([]); + expect( + projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs ?? [] + ).toStrictEqual([]); + expect(activator.removedMarketplaces).toStrictEqual([]); + expect(activator.upgradeCount).toBe(0); + expect(await fs.readFile("/foreign/cache/marker.json")).toBe("foreign bytes"); + } + ); + it.each([ + { + label: "unreadable CLI source", + reading: { + location: "copilot plugin marketplace list --json", + unreadable: + "Copilot CLI marketplace list --json unavailable; upgrade to a supported binary and retry", + }, + reason: "Copilot CLI marketplace list --json unavailable", + }, + { + label: "foreign effective source", + reading: { + location: "copilot effective marketplace list", + entries: new Map([ + [ + "same-name", + { + kind: "effective-list" as const, + root: "/foreign", + sourceType: "local", + source: "/foreign", + }, + ], + ]), + }, + reason: "host source unproven", + }, + ])( + "refuses Copilot activation with $label without changing declarative settings", + async ({ reading, reason }) => { + const projectRoot = "/A"; + const project = Manifest.create(); + project.addTool("copilot", "1.0.0", []); + project.addPlugin( + "copilot", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + "alias" + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: "alias", + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const fs = new InMemoryFileAdapter({ + "/built/copilot/.plugin/marketplace.json": JSON.stringify({ + name: "same-name", + plugins: [{ name: "test-plugin" }], + }), + }); + const settingsPath = `${projectRoot}/.github/copilot/settings.json`; + const foreignSettings = JSON.stringify({ + enabledPlugins: { "foreign@foreign-catalog": true }, + extraKnownMarketplaces: { "foreign-catalog": { source: "github" } }, + }); + fs.setFile(settingsPath, foreignSettings); + const activator = new FakeNativePluginActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => reading, + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["copilot", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + "copilot", + new FakeHostPluginRegistryReader({ location: "host-plugins", refs: new Map() }), + ], + ]), + machineRepo, + new Map([["copilot", source]]) + ); + const result = await sync.execute({ projectRoot }); + expect(result.warnings.join(" ")).toContain(reason); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(activator.upgradeCount).toBe(0); + expect(machineRepo.getCurrent()?.getNativeRegistrations("copilot")).toBeUndefined(); + expect(await fs.readFile(settingsPath)).toBe(foreignSettings); + } + ); + it("claims a fresh Codex source after post-read and attaches B without replacing it", async () => { + const projectRoot = "/A"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + "alias" + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: "alias", + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "same-name", + plugins: [{ name: "test-plugin" }], + }), + }); + const activator = new FakeNativePluginActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => ({ + location: "host", + entries: + activator.addedMarketplaces.length === 0 + ? new Map() + : new Map([ + [ + "same-name", + { + kind: "effective-list", + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }, + ], + ]), + }), + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + ["codex", new FakeHostPluginRegistryReader({ location: "host-plugins", refs: new Map() })], + ]), + machineRepo, + new Map([["codex", source]]) + ); + const result = await sync.execute({ projectRoot }); + expect(result.errors).toEqual([]); + expect(activator.addedMarketplaces).toEqual(["/built/codex"]); + expect(activator.enabledPlugins).toEqual(["test-plugin@same-name"]); + expect( + machineRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces[0].provenance + ).toEqual({ + kind: "effective-list", + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }); + const projectB = Manifest.create(); + projectB.addTool("codex", "1.0.0", []); + projectB.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + "alias" + ) + ); + const registryB = new InMemoryMarketplaceRegistry(); + await registryB.save( + "/B", + Marketplace.create({ + name: "alias", + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const syncB = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(projectB), + registryB, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + "codex", + new FakeHostPluginRegistryReader({ + location: "host-plugins", + refs: new Map([["test-plugin@same-name", { enabled: true }]]), + }), + ], + ]), + machineRepo, + new Map([["codex", source]]) + ); + await syncB.execute({ projectRoot: "/B" }); + expect(activator.addedMarketplaces).toEqual(["/built/codex"]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: "test-plugin@same-name", dependents: ["/A", "/B"] }, + ]); + }); + it("does not add, upgrade, or enable a foreign same-name Codex catalogue with a stale AIDD claim and empty host refs", async () => { + const projectRoot = "/A"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + "alias" + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { + alias: "alias", + hostName: "same-name", + provenance: { kind: "effective-list", root: "/old", sourceType: "local", source: "/old" }, + }, + ], + pluginRefs: [], + pluginClaims: [], + }); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: "alias", + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "same-name", + plugins: [{ name: "test-plugin" }], + }), + }); + fs.setFile("/foreign/marker.json", "foreign bytes"); + const activator = new FakeNativePluginActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => ({ + location: "host", + entries: new Map([ + [ + "same-name", + { kind: "effective-list", root: "/foreign", sourceType: "local", source: "/foreign" }, + ], + ]), + }), + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + ["codex", new FakeHostPluginRegistryReader({ location: "host-plugins", refs: new Map() })], + ]), + machineRepo, + new Map([["codex", source]]) + ); + const result = await sync.execute({ projectRoot }); + expect(result.warnings.join(" ")).toMatch(/same-name.*source.*reconcile manually/i); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(activator.upgradeCount).toBe(0); + expect(await fs.readFile("/foreign/marker.json")).toBe("foreign bytes"); + expect( + machineRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces[0].provenance?.source + ).toBe("/old"); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/native-plugin-claims.integration.test.ts b/cli/tests/contexts/framework/application/flows/native-plugin-claims.integration.test.ts new file mode 100644 index 000000000..47fbff596 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/native-plugin-claims.integration.test.ts @@ -0,0 +1,410 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { CleanUserScopeUseCase } from "../../../../../src/contexts/framework/application/clean/clean-user-scope-use-case.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; +import { UserMarketplaceRemoveUseCase } from "../../../../../src/contexts/framework/application/ownership/user-marketplace-remove-use-case.js"; +import { UserPluginDistributionLoader } from "../../../../../src/contexts/framework/application/ownership/user-plugin-distribution-loader.js"; +import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { UserPluginUpdateUseCase } from "../../../../../src/contexts/framework/application/ownership/user-plugin-update-use-case.js"; +import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { nativeActivationOf } from "../../../../../src/contexts/tools/domain/registry.js"; +import { resolveHomeDir } from "../../../../../src/kernel/reading/home-dir.js"; +import type { AiToolId } from "../../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +for (const [toolId, catalogPath] of [ + ["codex", ".agents/plugins/marketplace.json"], + ["copilot", ".plugin/marketplace.json"], +] as const) { + const effectiveSource = (hostName: string, source: string) => + new Map([ + [ + toolId, + { + read: async (projectRoot) => ({ + location: `/host/${toolId}/catalogue (cwd ${projectRoot})`, + entries: new Map([ + [ + hostName, + { kind: "effective-list" as const, root: source, sourceType: "local", source }, + ], + ]), + }), + }, + ], + ]); + describe(`${toolId} machine plugin claims`, () => { + it("global clean refuses live plugin dependents and preserves claims if host unregister fails", async () => { + const ref = "test-plugin@real-catalog"; + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [ + { + alias: "local-alias", + hostName: "real-catalog", + provenance: { + kind: "effective-list", + root: `/built/${toolId}`, + sourceType: "local", + source: `/built/${toolId}`, + }, + }, + ], + pluginRefs: [ref], + pluginClaims: [{ ref, dependents: ["/B"] }], + }); + const repo = new InMemoryManifestRepository(machine); + const fs = new InMemoryFileAdapter({ + [`/built/${toolId}/${catalogPath}`]: JSON.stringify({ name: "real-catalog", plugins: [] }), + }); + const registry = new InMemoryMarketplaceRegistry(); + const failing = new FakeNativePluginActivator({ available: true, failOnUninstall: [ref] }); + const sources = effectiveSource("real-catalog", `/built/${toolId}`); + const hostPlugins = new Map([ + [ + toolId, + { + read: async () => ({ + location: `/host/${toolId}/plugins`, + refs: new Map([[ref, { enabled: true, scope: "user" as const }]]), + }), + }, + ], + ]); + const clean = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + registry, + () => "/machine", + new Map([[toolId, failing]]), + new Map(), + () => "/home", + undefined, + undefined, + sources, + hostPlugins + ); + await expect(clean.execute({ projectRoot: "/B", force: true })).rejects.toThrow( + /active projects.*\/B/ + ); + expect(failing.uninstalledPlugins).toEqual([]); + expect( + repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims?.[0]?.dependents + ).toEqual(["/B"]); + const registrations = machine.getNativeRegistrations(toolId); + if (registrations === undefined) throw new Error("seeded machine claim missing"); + machine.setNativeRegistrations(toolId, { + ...registrations, + pluginClaims: [{ ref, dependents: [] }], + }); + await repo.save(machine); + await expect(clean.execute({ projectRoot: "/B", force: true })).rejects.toThrow( + /uninstall.*failed.*claims retained/ + ); + expect(repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref, dependents: [] }, + ]); + const working = new FakeNativePluginActivator({ available: true }); + const finish = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + registry, + () => "/machine", + new Map([[toolId, working]]), + new Map(), + () => "/home", + undefined, + undefined, + sources, + hostPlugins + ); + await finish.execute({ projectRoot: "/B", force: true }); + expect(working.uninstalledPlugins).toEqual([ref]); + expect(working.removedMarketplaces).toEqual(["real-catalog"]); + expect(repo.getCurrent()).toBeNull(); + }); + it("attaches B to AIDD's exact host ref without re-enabling it, but never claims a foreign ref", async () => { + const alias = "local-alias"; + const hostName = "real-catalog"; + const ref = `test-plugin@${hostName}`; + const builtDir = `/built/${toolId}`; + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: alias, + source: { kind: "local", path: "/src" }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const project = () => { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", []); + manifest.addPlugin( + toolId, + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/src" }, + false, + "project", + alias + ) + ); + return new InMemoryManifestRepository(manifest); + }; + const a = project(); + const b = project(); + const foreign = project(); + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [ + { + alias, + hostName, + provenance: { + kind: "effective-list", + root: builtDir, + sourceType: "local", + source: builtDir, + }, + }, + ], + pluginRefs: [], + pluginClaims: [], + }); + const user = new InMemoryManifestRepository(machine); + const activator = new FakeNativePluginActivator({ available: true }); + const sources = effectiveSource(hostName, builtDir); + let enabled = false; + const hostReader: HostPluginRegistryReader = { + read: async () => ({ + location: "/host/registry", + refs: new Map(enabled ? [[ref, { enabled: true, scope: "user" as const }]] : []), + }), + }; + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({ + [`${builtDir}/${catalogPath}`]: JSON.stringify({ + name: hostName, + version: "1.0.0", + plugins: [], + }), + }), + a, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([[toolId, activator]]), + fakeEnsureBuiltMarketplace(() => builtDir), + new Map(), + () => "/built", + undefined, + undefined, + undefined, + new Map([[toolId, hostReader]]), + user, + sources + ); + + await useCase.execute({ projectRoot: "/A", manifestRepo: a, toolIds: [toolId] }); + enabled = true; + await useCase.execute({ projectRoot: "/B", manifestRepo: b, toolIds: [toolId] }); + expect(activator.enabledPlugins).toEqual([ref]); + expect(b.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toContain(ref); + expect(user.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref, dependents: ["/A", "/B"] }, + ]); + const updateFs = new InMemoryFileAdapter(); + const update = new UserPluginUpdateUseCase( + user, + new UserPluginFileUpdater( + updateFs, + new UserPluginDistributionLoader( + new FixturePluginFetcher(), + new PluginDistributionReaderAdapter(updateFs) + ), + new DeterministicHasher() + ), + new CapturingLogger(), + new NativeHostRegistrationGate( + new Map([[toolId, activator]]), + new Map([[toolId, hostReader]]), + sources + ) + ); + if (toolId === "copilot") { + expect( + await update.execute({ + pluginNames: ["test-plugin"], + toolIds: [toolId], + projectRoot: "/A", + scope: "user", + }) + ).toEqual([ref]); + expect(activator.updatedPlugins).toEqual([ref]); + const failed = new FakeNativePluginActivator({ available: true, failOnUpdate: [ref] }); + const failingUpdate = new UserPluginUpdateUseCase( + user, + new UserPluginFileUpdater( + updateFs, + new UserPluginDistributionLoader( + new FixturePluginFetcher(), + new PluginDistributionReaderAdapter(updateFs) + ), + new DeterministicHasher() + ), + new CapturingLogger(), + new NativeHostRegistrationGate( + new Map([[toolId, failed]]), + new Map([[toolId, hostReader]]), + sources + ) + ); + await expect( + failingUpdate.execute({ + pluginNames: ["test-plugin"], + toolIds: [toolId], + projectRoot: "/A", + scope: "user", + }) + ).rejects.toThrow(/plugin update.*failed/); + expect(user.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref, dependents: ["/A", "/B"] }, + ]); + } else { + await expect( + update.execute({ + pluginNames: ["test-plugin"], + toolIds: [toolId], + projectRoot: "/A", + scope: "user", + }) + ).rejects.toThrow(/does not support targeted.*update/); + expect(activator.updatedPlugins).toEqual([]); + } + const removeMarketplace = new UserMarketplaceRemoveUseCase( + new InMemoryFileAdapter(), + user, + registry, + new NativeHostRegistrationGate( + new Map([[toolId, activator]]), + new Map([[toolId, hostReader]]), + sources + ) + ); + await expect( + removeMarketplace.execute({ + name: alias, + projectRoot: "/A", + autoConfirm: true, + scope: "user", + }) + ).rejects.toThrow(/active projects/); + + const foreignUser = new InMemoryManifestRepository(Manifest.create()); + const foreignUseCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({ + [`${builtDir}/${catalogPath}`]: JSON.stringify({ + name: hostName, + version: "1.0.0", + plugins: [], + }), + }), + foreign, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([[toolId, new FakeNativePluginActivator({ available: true })]]), + fakeEnsureBuiltMarketplace(() => builtDir), + new Map(), + () => "/built", + undefined, + undefined, + undefined, + new Map([[toolId, hostReader]]), + foreignUser, + sources + ); + await foreignUseCase.execute({ + projectRoot: "/foreign", + manifestRepo: foreign, + toolIds: [toolId], + }); + expect(foreign.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual([]); + expect(foreignUser.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims ?? []).toEqual( + [] + ); + const removeFs = new InMemoryFileAdapter(); + const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(resolveHomeDir()); + const cachedBytes = + cacheRoot === undefined ? undefined : join(cacheRoot, hostName, "test-plugin", "skill.md"); + if (cachedBytes !== undefined) + removeFs.setFile(cachedBytes, "B still uses this cached plugin"); + const remove = (repo: InMemoryManifestRepository) => + new PluginRemoveUseCase( + removeFs, + repo, + new CapturingLogger(), + new Map([[toolId, activator]]), + new Map([[toolId, hostReader]]), + undefined, + registry, + user, + sources + ); + await remove(a).execute({ pluginName: "test-plugin", toolIds: [toolId], projectRoot: "/A" }); + expect(activator.uninstalledPlugins).toEqual([]); + if (cachedBytes !== undefined) + expect(removeFs.getFile(cachedBytes)).toBe("B still uses this cached plugin"); + expect(user.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref, dependents: ["/B"] }, + ]); + await remove(b).execute({ pluginName: "test-plugin", toolIds: [toolId], projectRoot: "/B" }); + expect(activator.uninstalledPlugins).toEqual([]); + expect(user.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref, dependents: [] }, + ]); + await remove(a).execute({ + pluginName: "test-plugin", + toolIds: [toolId], + projectRoot: "/A", + scope: "user", + }); + expect(activator.uninstalledPlugins).toEqual([ref]); + expect(user.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + enabled = false; + await removeMarketplace.execute({ + name: alias, + projectRoot: "/A", + autoConfirm: true, + scope: "user", + }); + expect(activator.removedMarketplaces).toEqual([hostName]); + expect((await registry.list("/A")).find((entry) => entry.name === alias)).toBeUndefined(); + }); + }); +} diff --git a/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts b/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts index 56015deba..b95b6ddf3 100644 --- a/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts @@ -29,7 +29,8 @@ async function installWithLogger(toolId: "codex" | "claude") { deps.hasher, logger, new InMemoryMarketplaceRegistry(), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, diff --git a/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts b/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts index b9748eee7..25ac41a91 100644 --- a/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts @@ -29,7 +29,8 @@ async function installSamplePlugin() { deps.hasher, capturingLogger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, diff --git a/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts index a2696bb00..60ad9e0ac 100644 --- a/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts @@ -1,5 +1,5 @@ import "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { Marketplace } from "../../../../../../src/contexts/distribution/domain/marketplace.js"; import { BuiltTreeMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/built-tree-materialization-translator.js"; import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; @@ -37,6 +37,43 @@ async function makeRegistry(): Promise { } describe("BuiltTreeMaterializationTranslator — cursor (integration)", () => { + it("counts changed files and skips byte-identical files on repeated materialization", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/plugins/sample-plugin/skills/demo/SKILL.md`, "built skill"); + const translator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => HOME, + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + const install = () => { + const restored = Manifest.create(); + restored.addTool("cursor", "test", []); + return translator.addPlugin( + dist(), + "cursor", + { kind: "local", path: "/source" }, + PROJECT_ROOT, + restored, + "aidd-framework" + ); + }; + const first = await install(); + expect(first.written).toBe(1); + const writes = vi.spyOn(fs, "writeFile"); + expect((await install()).written).toBe(0); + expect(writes).not.toHaveBeenCalled(); + fs.setFile( + `${HOME}/.cursor/plugins/local/sample-plugin/skills/demo/SKILL.md`, + "changed by user" + ); + expect((await install()).written).toBe(1); + expect( + await fs.readFile(`${HOME}/.cursor/plugins/local/sample-plugin/skills/demo/SKILL.md`) + ).toBe("built skill"); + }); + it("copies the built plugin subtree verbatim into the user plugin dir", async () => { const fs = new InMemoryFileAdapter(); // Built cursor tree (transformed content already): @ expanded, .mdc rule, dotted .mcp.json. @@ -78,6 +115,36 @@ describe("BuiltTreeMaterializationTranslator — cursor (integration)", () => { expect(installed?.files.size).toBe(4); }); + it("does not rewrite or claim an occupied Cursor user plugin directory", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/plugins/sample-plugin/skills/demo/SKILL.md`, "built bytes"); + const occupied = `${HOME}/.cursor/plugins/local/sample-plugin/skills/demo/SKILL.md`; + fs.setFile(occupied, "foreign bytes"); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + const translator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => HOME, + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + + const result = await translator.addPlugin( + dist(), + "cursor", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework", + new Map(), + true + ); + expect(result.written).toBe(0); + expect(fs.getFile(occupied)).toBe("foreign bytes"); + expect(manifest.getPlugins("cursor")[0]?.files.size).toBe(0); + }); + it("falls back to flat materialization when no marketplace is given (raw local install)", async () => { const fs = new InMemoryFileAdapter(); const manifest = Manifest.create(); diff --git a/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts index 6b2857c0d..8ecc56302 100644 --- a/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts @@ -1,9 +1,11 @@ import "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { describe, expect, it } from "vitest"; import { Marketplace } from "../../../../../../src/contexts/distribution/domain/marketplace.js"; import { BuiltTreeMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/built-tree-materialization-translator.js"; import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { InstallationFile } from "../../../../../../src/kernel/file.js"; import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; @@ -11,6 +13,7 @@ import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-mem const PROJECT_ROOT = "/proj"; const BUILT = "/built/opencode"; +const KILO_BUILT = "/built/kilo"; function dist(): PluginDistribution { return new PluginDistribution({ @@ -192,3 +195,94 @@ describe("BuiltTreeMaterializationTranslator — what the opencode flat tree nev ]); }); }); + +describe("BuiltTreeMaterializationTranslator — kilo (integration)", () => { + it("copies Kilo's namespaced flat files and the generated bridge", async () => { + const fs = new InMemoryFileAdapter(); + const hasher = new DeterministicHasher(); + const configPath = `${PROJECT_ROOT}/.kilo/kilo.jsonc`; + const baseConfig = '{\n "$schema": "https://app.kilo.ai/config.json"\n}'; + fs.setFile(configPath, baseConfig); + fs.setFile(`${KILO_BUILT}/.kilo/skills/aidd-vcs/01-commit/SKILL.md`, "skill"); + fs.setFile(`${KILO_BUILT}/.kilo/agents/aidd-vcs-helper.md`, "agent"); + fs.setFile(`${KILO_BUILT}/.kilo/hooks/aidd-vcs/update_memory.js`, "hook"); + fs.setFile(`${KILO_BUILT}/.kilo/plugin/aidd-vcs-hooks.js`, "bridge"); + fs.setFile(`${KILO_BUILT}/.kilo/agents/aidd-dev-helper.md`, "other"); + + const manifest = Manifest.create(); + manifest.addTool("kilo", "test", [ + new InstallationFile({ + relativePath: ".kilo/kilo.jsonc", + content: baseConfig, + hash: hasher.hash(baseConfig), + }), + ]); + const translator = new BuiltTreeMaterializationTranslator( + fs, + hasher, + () => "/home/u", + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + const distribution = new PluginDistribution({ + manifest: { name: "aidd-vcs", version: "1.0.0" }, + format: "claude", + files: [], + components: { + commands: [], + agents: [], + rules: [], + skills: [], + mcp: [ + { + relativePath: ".mcp.json", + content: JSON.stringify({ + mcpServers: { context: { command: "node", args: ["server.js"] } }, + }), + }, + ], + hooks: [ + { relativePath: "hooks/hooks.json", content: sessionStartHooksJson() }, + { relativePath: "hooks/update_memory.js", content: "hook" }, + ], + }, + }); + + await translator.addPlugin( + distribution, + "kilo", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework" + ); + + expect(fs.getFile(`${PROJECT_ROOT}/.kilo/skills/aidd-vcs/01-commit/SKILL.md`)).toBe("skill"); + expect(fs.getFile(`${PROJECT_ROOT}/.kilo/agents/aidd-vcs-helper.md`)).toBe("agent"); + expect(fs.getFile(`${PROJECT_ROOT}/.kilo/hooks/aidd-vcs/update_memory.js`)).toBe("hook"); + expect(fs.getFile(`${PROJECT_ROOT}/.kilo/plugin/aidd-vcs-hooks.js`)).toBe("bridge"); + expect(fs.has(`${PROJECT_ROOT}/.kilo/agents/aidd-dev-helper.md`)).toBe(false); + const config = JSON.parse(await fs.readFile(`${PROJECT_ROOT}/.kilo/kilo.jsonc`)) as { + mcp: Record; + }; + expect(config.mcp.context.type).toBe("local"); + expect( + manifest + .getPlugins("kilo") + .find((p) => p.name === "aidd-vcs") + ?.mcpEntries.get("context") + ).toBeTruthy(); + expect(manifest.getToolFiles("kilo")[0]?.hash).toEqual( + hasher.hash(await fs.readFile(configPath)) + ); + }); +}); + +function sessionStartHooksJson(): string { + const root = "$" + "{CLAUDE_PLUGIN_ROOT}"; + return JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ command: `node ${root}/hooks/update_memory.js` }] }], + }, + }); +} diff --git a/cli/tests/contexts/framework/application/framework/translator/install-plugin-claude-mode-a.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-claude-mode-a.integration.test.ts index 5161e01fa..ceb829395 100644 --- a/cli/tests/contexts/framework/application/framework/translator/install-plugin-claude-mode-a.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-claude-mode-a.integration.test.ts @@ -9,6 +9,8 @@ import { PluginDistribution } from "../../../../../../src/contexts/translate/dom import { CapturingLogger } from "../../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -17,6 +19,44 @@ import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-mem const PROJECT_ROOT = "/test-project"; const MARKETPLACE_NAME = "aidd-framework"; +function syncWithProvenFreshHost( + fs: InMemoryFileAdapter, + manifestRepo: InMemoryManifestRepository, + registry: InMemoryMarketplaceRegistry, + hasher: DeterministicHasher, + activator: FakeNativePluginActivator +): MarketplaceSyncSettingsUseCase { + return new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + ["claude", new FakeHostPluginRegistryReader({ location: "fake claude", refs: new Map() })], + ]), + undefined, + new Map([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === "/built/claude" ? MARKETPLACE_NAME : undefined), + new Map() + ), + ], + ]) + ); +} + /** A readable catalog at the path the default `fakeEnsureBuiltMarketplace()` resolves * "claude" to — a real build always leaves one there, and an unreadable one now fails hard. */ async function seedBuiltCatalog(fs: InMemoryFileAdapter, name = MARKETPLACE_NAME): Promise { @@ -73,15 +113,7 @@ describe("install claude plugin via Mode A (integration)", () => { }) ); - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - hasher, - new CapturingLogger(), - new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() - ); + const useCase = syncWithProvenFreshHost(fs, manifestRepo, registry, hasher, activator); await useCase.execute({ projectRoot: PROJECT_ROOT }); const shared = JSON.parse( @@ -150,15 +182,7 @@ describe("install claude plugin via Mode A (integration)", () => { }) ); - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - hasher, - new CapturingLogger(), - new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() - ); + const useCase = syncWithProvenFreshHost(fs, manifestRepo, registry, hasher, activator); // What a project installed before the split looks like: the registration sitting in // the committed file, naming a path that belongs to whoever ran the install. diff --git a/cli/tests/contexts/framework/application/framework/translator/install-plugin-codex-mode-a.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-codex-mode-a.integration.test.ts index b917af5df..4e587513b 100644 --- a/cli/tests/contexts/framework/application/framework/translator/install-plugin-codex-mode-a.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-codex-mode-a.integration.test.ts @@ -12,6 +12,8 @@ import type { PluginSource } from "../../../../../../src/kernel/source.js"; import { CapturingLogger } from "../../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -20,6 +22,45 @@ import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-mem const PROJECT_ROOT = "/test-project"; const MARKETPLACE_NAME = "aidd-framework"; +function syncWithProvenFreshHost( + fs: InMemoryFileAdapter, + manifestRepo: InMemoryManifestRepository, + registry: InMemoryMarketplaceRegistry, + hasher: DeterministicHasher, + logger: CapturingLogger, + activator: FakeNativePluginActivator +): MarketplaceSyncSettingsUseCase { + return new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + logger, + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + ["codex", new FakeHostPluginRegistryReader({ location: "fake codex", refs: new Map() })], + ]), + undefined, + new Map([ + [ + "codex", + new FakeNativeMarketplaceSourceReader( + activator, + "effective-list", + (path) => (path === "/built/codex" ? MARKETPLACE_NAME : undefined), + new Map() + ), + ], + ]) + ); +} + /** A readable catalog at the path `fakeEnsureBuiltMarketplace()` resolves "codex" to, at its * own `distributionProbes.marketplace` relative path: a real build always leaves one there, and * an unreadable catalog is now `UnreadableBuiltCatalogError` rather than a fall back. */ @@ -112,14 +153,13 @@ describe("install codex plugin via Mode A (integration)", () => { const activator = new FakeNativePluginActivator({ available: true }); await seedCodexPlugin(manifestRepo, registry); - const useCase = new MarketplaceSyncSettingsUseCase( + const useCase = syncWithProvenFreshHost( fs, manifestRepo, registry, hasher, new CapturingLogger(), - new Map([["codex", activator]]), - fakeEnsureBuiltMarketplace() + activator ); await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -127,7 +167,7 @@ describe("install codex plugin via Mode A (integration)", () => { // succeeds outright — no pre-emptive remove on a clean install. expect(activator.removedMarketplaces).toEqual([]); expect(activator.addedMarketplaces).toEqual(["/built/codex"]); - expect(activator.upgradeCount).toBe(1); + expect(activator.upgradeCount).toBe(0); expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); expect(await fs.fileExists(resolve(PROJECT_ROOT, ".codex/config.json"))).toBe(false); }); @@ -143,14 +183,13 @@ describe("install codex plugin via Mode A (integration)", () => { repo: "ai-driven-dev/framework", }); - const useCase = new MarketplaceSyncSettingsUseCase( + const useCase = syncWithProvenFreshHost( fs, manifestRepo, registry, new DeterministicHasher(), new CapturingLogger(), - new Map([["codex", activator]]), - fakeEnsureBuiltMarketplace() + activator ); await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -170,14 +209,13 @@ describe("install codex plugin via Mode A (integration)", () => { }); await seedTwoCodexPlugins(manifestRepo, registry); - const useCase = new MarketplaceSyncSettingsUseCase( + const useCase = syncWithProvenFreshHost( fs, manifestRepo, registry, new DeterministicHasher(), logger, - new Map([["codex", activator]]), - fakeEnsureBuiltMarketplace() + activator ); await useCase.execute({ projectRoot: PROJECT_ROOT }); diff --git a/cli/tests/contexts/framework/application/framework/translator/install-plugin-copilot-mode-a.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-copilot-mode-a.integration.test.ts index 38202cfdc..9dacea07a 100644 --- a/cli/tests/contexts/framework/application/framework/translator/install-plugin-copilot-mode-a.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-copilot-mode-a.integration.test.ts @@ -9,6 +9,8 @@ import { PluginDistribution } from "../../../../../../src/contexts/translate/dom import { CapturingLogger } from "../../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -17,6 +19,30 @@ import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-mem const PROJECT_ROOT = "/test-project"; const MARKETPLACE_NAME = "aidd-framework"; +function ownedMachineCatalog(): InMemoryManifestRepository { + const machine = Manifest.create(); + machine.addTool("copilot", "test", []); + machine.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [{ alias: MARKETPLACE_NAME, hostName: MARKETPLACE_NAME }], + pluginRefs: [], + pluginClaims: [], + }); + return new InMemoryManifestRepository(machine); +} + +function readableHostPlugins() { + return new Map([ + [ + "copilot" as const, + new FakeHostPluginRegistryReader({ + location: "/home/.copilot/plugins", + refs: new Map(), + }), + ], + ]); +} + /** A real build always leaves a catalog at copilot's own `distributionProbes.marketplace` * path, and an unreadable one is a hard failure, so the fixture must leave one too. */ async function seedBuiltCatalog(fs: InMemoryFileAdapter, name = MARKETPLACE_NAME): Promise { @@ -69,12 +95,18 @@ function buildDist(name = "aidd-context"): PluginDistribution { } describe("install copilot plugin via Mode A (integration)", () => { - it("recommends plugins in the shared file and puts no path in it", async () => { + it("does not auto-install an unproven plugin or rewrite a foreign marketplace overlay", async () => { const fs = new InMemoryFileAdapter(); const hasher = new DeterministicHasher(); const manifestRepo = new InMemoryManifestRepository(); const registry = new InMemoryMarketplaceRegistry(); await seedCopilotPlugin(manifestRepo, registry); + const settingsPath = resolve(PROJECT_ROOT, ".github/copilot/settings.json"); + const foreignSettings = JSON.stringify({ + enabledPlugins: { "foreign@foreign-catalog": true }, + extraKnownMarketplaces: { "foreign-catalog": { source: "github" } }, + }); + await fs.writeFile(settingsPath, foreignSettings); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -87,19 +119,10 @@ describe("install copilot plugin via Mode A (integration)", () => { ); await useCase.execute({ projectRoot: PROJECT_ROOT }); - const settingsPath = resolve(PROJECT_ROOT, ".github/copilot/settings.json"); - const settings = JSON.parse(await fs.readFile(settingsPath)) as Record; - - // VS Code reads this file to recommend plugins to teammates, so it carries names. - expect(settings.enabledPlugins).toBeDefined(); - - // No marketplace registration: that names the built tree by absolute path, which belongs - // to whoever ran the install; copilot learns its marketplaces from its own CLI instead. - expect(settings.extraKnownMarketplaces).toBeUndefined(); - expect(JSON.stringify(settings)).not.toContain("/built/copilot"); + expect(await fs.readFile(settingsPath)).toBe(foreignSettings); }); - it("drives the copilot CLI activator and still writes the settings file", async () => { + it("projects only a plugin ref whose marketplace source a future host reader verifies", async () => { const fs = new InMemoryFileAdapter(); await seedBuiltCatalog(fs); const manifestRepo = new InMemoryManifestRepository(); @@ -114,25 +137,40 @@ describe("install copilot plugin via Mode A (integration)", () => { new DeterministicHasher(), new CapturingLogger(), new Map([["copilot", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + new Map([ + [ + "copilot", + new FakeNativeMarketplaceSourceReader( + activator, + "effective-list", + (path) => (path === "/built/copilot" ? MARKETPLACE_NAME : undefined), + new Map() + ), + ], + ]) ); await useCase.execute({ projectRoot: PROJECT_ROOT }); - // Registers the BUILT copilot tree (not the raw github source). A fresh add - // succeeds outright — no pre-emptive remove. expect(activator.removedMarketplaces).toEqual([]); expect(activator.addedMarketplaces).toEqual(["/built/copilot"]); expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); expect(await fs.fileExists(resolve(PROJECT_ROOT, ".github/copilot/settings.json"))).toBe(true); }); - it("takes the name back when whoever held it is gone", async () => { + it("refuses force takeover of a claimed Copilot name without host source proof", async () => { const fs = new InMemoryFileAdapter(); await seedBuiltCatalog(fs); const manifestRepo = new InMemoryManifestRepository(); const registry = new InMemoryMarketplaceRegistry(); - // The name is held, and the tool reports its source no longer resolves: nobody - // alive is behind it, so taking it back breaks nothing. + // A missing source does not by itself prove who owns the host registration. const activator = new FakeNativePluginActivator({ available: true, conflictOnAdd: true, @@ -147,15 +185,50 @@ describe("install copilot plugin via Mode A (integration)", () => { new DeterministicHasher(), new CapturingLogger(), new Map([["copilot", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + ownedMachineCatalog() ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(activator.removedMarketplaces).toEqual([MARKETPLACE_NAME]); - // Forced, because a marketplace with plugins installed refuses a plain removal. - expect(activator.forcedRemovals).toEqual([true]); - expect(activator.addedMarketplaces).toEqual(["/built/copilot"]); - expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.forcedRemovals).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(result.warnings.join("\n")).toContain("host source unproven"); + }); + + it("refuses force takeover of a dead name without a canonical owner", async () => { + const fs = new InMemoryFileAdapter(); + await seedBuiltCatalog(fs); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + await seedCopilotPlugin(manifestRepo, registry); + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "dead", + }); + const result = await new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["copilot", activator]]), + fakeEnsureBuiltMarketplace() + ).execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.forcedRemovals).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(result.warnings.join("\n")).toContain("host source unproven"); }); it("leaves a name alone while it still resolves, whoever holds it", async () => { @@ -184,7 +257,9 @@ describe("install copilot plugin via Mode A (integration)", () => { ).execute({ projectRoot: PROJECT_ROOT }); expect(activator.removedMarketplaces).toEqual([]); - expect(logger.warnMessages.some((m) => m.includes("register marketplace"))).toBe(true); + expect(logger.warnMessages.some((m) => m.includes("host source unproven"))).toBe(true); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); }); it("says nothing about taking a name back when it cannot tell who holds it", async () => { @@ -215,7 +290,8 @@ describe("install copilot plugin via Mode A (integration)", () => { expect(activator.removedMarketplaces).toEqual([]); expect(logger.warnMessages.some((m) => m.includes("no longer exists"))).toBe(false); - // The failure itself is still surfaced, not swallowed. - expect(logger.warnMessages.some((m) => m.includes("register marketplace"))).toBe(true); + expect(logger.warnMessages.some((m) => m.includes("host source unproven"))).toBe(true); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); }); }); diff --git a/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts index 5df09df4c..f5723c31d 100644 --- a/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts @@ -182,24 +182,28 @@ describe("ModeBFlatMaterializationTranslator", () => { }); } - it("drops the servers a previous version contributed even when the new version contributes none", async () => { + it("refuses to unmerge an old server whose recorded digest cannot prove its current bytes", async () => { const { adapter, fs } = buildAdapter(); const configPath = join(PROJECT_ROOT, "opencode.json"); - fs.setFile(configPath, JSON.stringify({ mcp: { "old-tool": { type: "local" } } })); + const userContent = JSON.stringify({ mcp: { "old-tool": { type: "local" } } }); + fs.setFile(configPath, userContent); const manifest = Manifest.create(); manifest.addTool("opencode", "test", []); - await adapter.addPlugin( - mcpDist({}), - "opencode", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - undefined, - new Map([["old-tool", "digest-of-old-tool"]]) - ); + await expect( + adapter.addPlugin( + mcpDist({}), + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined, + new Map([["old-tool", "digest-of-old-tool"]]) + ) + ).rejects.toThrow(/edited after install/); - expect(JSON.parse(fs.getFile(configPath) ?? "null")).toStrictEqual({ mcp: {} }); + expect(fs.getFile(configPath)).toBe(userContent); + expect(manifest.getPlugins("opencode")).toEqual([]); }); it("propagates a failure to read the MCP config other than the file being absent", async () => { diff --git a/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts index 6f972cc29..7fd864ed5 100644 --- a/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts @@ -1,6 +1,6 @@ import "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ProjectHooksMaterializer, withoutHooks, @@ -53,6 +53,119 @@ function distWithHooks(hooks: readonly PluginComponentFile[]): PluginDistributio } describe("ProjectHooksMaterializer", () => { + it("refuses a preexisting user-owned script without changing the project's hooks", async () => { + const scriptPath = join(PROJECT_ROOT, ".cursor/hooks", PLUGIN_NAME, "extra.js"); + const fs = new InMemoryFileAdapter(); + const materializer = new ProjectHooksMaterializer(fs); + const first = await materializer.materializeWithProvenance( + distWithHooks([hooksManifest("PreToolUse"), SCRIPT]), + "cursor", + PROJECT_ROOT + ); + const originalHooks = fs.getFile(HOOKS_PATH); + fs.setFile(scriptPath, "user script"); + + await expect( + materializer.materializeWithProvenance( + distWithHooks([ + hooksManifest("PreToolUse"), + SCRIPT, + { relativePath: "hooks/extra.js", content: "new owned script" }, + ]), + "cursor", + PROJECT_ROOT, + first.projectHooks + ) + ).rejects.toThrow(/user-owned.*install refused/); + + expect(fs.getFile(scriptPath)).toBe("user script"); + expect(fs.getFile(HOOKS_PATH)).toBe(originalHooks); + }); + + it.each(["present", "already missing"] as const)( + "reinstall removes only the obsolete owned script when it is %s", + async (state) => { + const fs = new InMemoryFileAdapter(); + const materializer = new ProjectHooksMaterializer(fs); + const oldPath = join(PROJECT_ROOT, ".cursor/hooks", PLUGIN_NAME, "old.js"); + const scriptPath = join(PROJECT_ROOT, ".cursor/hooks", PLUGIN_NAME, "pre.js"); + const userPath = join(PROJECT_ROOT, ".cursor/hooks", PLUGIN_NAME, "notes.txt"); + const otherPath = join(PROJECT_ROOT, ".cursor/hooks/other-plugin/check.js"); + const first = await materializer.materializeWithProvenance( + distWithHooks([ + hooksManifest("PreToolUse"), + SCRIPT, + { relativePath: "hooks/old.js", content: "old script" }, + ]), + "cursor", + PROJECT_ROOT + ); + fs.setFile(userPath, "user notes"); + fs.setFile(otherPath, "other plugin script"); + const previousHooks = fs.getFile(HOOKS_PATH); + if (state === "already missing") await fs.deleteFile(oldPath); + + const second = await materializer.materializeWithProvenance( + distWithHooks([ + hooksManifest("PreToolUse"), + { ...SCRIPT, content: "updated owned script" }, + ]), + "cursor", + PROJECT_ROOT, + first.projectHooks + ); + + expect(fs.getFile(oldPath)).toBeUndefined(); + expect(fs.getFile(scriptPath)).toBe("updated owned script"); + expect(fs.getFile(userPath)).toBe("user notes"); + expect(fs.getFile(otherPath)).toBe("other plugin script"); + expect(fs.getFile(HOOKS_PATH)).toBe(previousHooks); + expect(second.skipped).toStrictEqual([]); + expect(second.projectHooks?.scripts).toStrictEqual( + new Map([ + [`.cursor/hooks/${PLUGIN_NAME}/pre.js`, (await fs.readFileHash(scriptPath)).value], + ]) + ); + } + ); + + it("preserves an obsolete script edited after preflight instead of deleting it during reinstall", async () => { + const fs = new InMemoryFileAdapter(); + const materializer = new ProjectHooksMaterializer(fs); + const oldPath = join(PROJECT_ROOT, ".cursor/hooks", PLUGIN_NAME, "old.js"); + const scriptPath = join(PROJECT_ROOT, ".cursor/hooks", PLUGIN_NAME, "pre.js"); + const first = await materializer.materializeWithProvenance( + distWithHooks([ + hooksManifest("PreToolUse"), + SCRIPT, + { relativePath: "hooks/old.js", content: "old script" }, + ]), + "cursor", + PROJECT_ROOT + ); + const writeFile = fs.writeFile.bind(fs); + vi.spyOn(fs, "writeFile").mockImplementation(async (path, content) => { + if (path === scriptPath && content === "updated owned script") + fs.setFile(oldPath, "concurrent user edit"); + return writeFile(path, content); + }); + + await expect( + materializer.materializeWithProvenance( + distWithHooks([ + hooksManifest("PreToolUse"), + { ...SCRIPT, content: "updated owned script" }, + ]), + "cursor", + PROJECT_ROOT, + first.projectHooks + ) + ).rejects.toThrow(/edited during reinstall.*removal refused/); + + expect(fs.getFile(oldPath)).toBe("concurrent user edit"); + expect(first.projectHooks?.scripts.has(`.cursor/hooks/${PLUGIN_NAME}/old.js`)).toBe(true); + }); + it("merges the plugin's hooks manifest whichever position it holds among the hook files", async () => { const fs = new InMemoryFileAdapter(); diff --git a/cli/tests/contexts/framework/application/framework/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts index f1ce48bcd..530b95367 100644 --- a/cli/tests/contexts/framework/application/framework/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest"; import { ModeBFlatMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.js"; import { PluginRemoveUseCase } from "../../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import type { ProjectHooksProvenance } from "../../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; import { CLIOutput } from "../../../../../../src/presentation/output.js"; import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; @@ -65,7 +66,28 @@ function buildDist(name: string): PluginDistribution { }); } -async function installPlugin(fs: InMemoryFileAdapter, manifest: Manifest, name: string) { +function buildWithoutHooks(name: string): PluginDistribution { + return new PluginDistribution({ + manifest: { name, version: "1.1.0" }, + format: "claude", + files: [{ relativePath: ".mcp.json", content: MCP_CONTENT }], + components: { + commands: [], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [{ relativePath: ".mcp.json", content: MCP_CONTENT }], + }, + }); +} + +async function installPlugin( + fs: InMemoryFileAdapter, + manifest: Manifest, + name: string, + previous?: ProjectHooksProvenance +) { const adapter = new ModeBFlatMaterializationTranslator( fs, new DeterministicHasher(), @@ -77,7 +99,10 @@ async function installPlugin(fs: InMemoryFileAdapter, manifest: Manifest, name: { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined + undefined, + new Map(), + false, + previous ); } @@ -102,6 +127,10 @@ describe("Cursor plugin.files tracking enables uninstall of mcp.json; hooks.json } // hooks.json was still written - just not tracked in Plugin.files, and not here expect(fs.has(HOOKS_PATH)).toBe(true); + expect(installed?.projectHooks?.entries).toHaveLength(1); + expect([...(installed?.projectHooks?.scripts ?? new Map()).keys()]).toEqual([ + `.cursor/hooks/${PLUGIN_NAME}/pre.js`, + ]); }); }); @@ -146,12 +175,112 @@ describe("plugin remove unmerges Cursor project hooks (Phase 7, Task 3)", () => const manifest = Manifest.create(); manifest.addTool("cursor", "test", []); await installPlugin(fs, manifest, PLUGIN_NAME); + const previous = manifest.getPlugins("cursor")[0].projectHooks; manifest.removePlugin("cursor", PLUGIN_NAME); - await installPlugin(fs, manifest, PLUGIN_NAME); + await installPlugin(fs, manifest, PLUGIN_NAME, previous); const parsed = JSON.parse(await fs.readFile(HOOKS_PATH)) as { hooks: Record>; }; expect(parsed.hooks.preToolUse).toHaveLength(1); }); + + it("refuses reinstall if the previous hook script was edited", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + await installPlugin(fs, manifest, PLUGIN_NAME); + const previous = manifest.getPlugins("cursor")[0].projectHooks; + fs.setFile(SCRIPT_PATH, "edited by user"); + manifest.removePlugin("cursor", PLUGIN_NAME); + await expect(installPlugin(fs, manifest, PLUGIN_NAME, previous)).rejects.toThrow(/edited/); + expect(fs.getFile(SCRIPT_PATH)).toBe("edited by user"); + }); + + it("removes verified prior project hooks when a replacement no longer carries hooks", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + await installPlugin(fs, manifest, PLUGIN_NAME); + const previous = manifest.getPlugins("cursor")[0].projectHooks; + manifest.removePlugin("cursor", PLUGIN_NAME); + const translator = new ModeBFlatMaterializationTranslator( + fs, + new DeterministicHasher(), + () => STUB_HOME + ); + await translator.addPlugin( + buildWithoutHooks(PLUGIN_NAME), + "cursor", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined, + new Map(), + false, + previous + ); + expect(fs.has(HOOKS_PATH)).toBe(false); + expect(fs.has(SCRIPT_PATH)).toBe(false); + expect(manifest.getPlugins("cursor")[0].projectHooks).toBeUndefined(); + }); + + it("refuses to remove an edited project hook script without detaching the plugin record", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + await installPlugin(fs, manifest, PLUGIN_NAME); + fs.setFile(SCRIPT_PATH, "user-edited script"); + const repo = new InMemoryManifestRepository(manifest); + const remove = new PluginRemoveUseCase(fs, repo, new CLIOutput(false), new Map()); + await expect( + remove.execute({ pluginName: PLUGIN_NAME, toolIds: ["cursor"], projectRoot: PROJECT_ROOT }) + ).rejects.toThrow(/edited.*pre.js|pre.js.*edited/); + expect(fs.getFile(SCRIPT_PATH)).toBe("user-edited script"); + expect( + repo + .getCurrent() + ?.getPlugins("cursor") + .some((p) => p.name === PLUGIN_NAME) + ).toBe(true); + }); + + it("refuses to strip an edited hook command entry and preserves other plugins", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + await installPlugin(fs, manifest, PLUGIN_NAME); + await installPlugin(fs, manifest, OTHER_PLUGIN_NAME); + const hooks = JSON.parse(fs.getFile(HOOKS_PATH) ?? "null") as { + hooks: { preToolUse: Array<{ command: string }> }; + }; + hooks.hooks.preToolUse[0].command += " --user-flag"; + fs.setFile(HOOKS_PATH, JSON.stringify(hooks)); + const repo = new InMemoryManifestRepository(manifest); + const remove = new PluginRemoveUseCase(fs, repo, new CLIOutput(false), new Map()); + await expect( + remove.execute({ pluginName: PLUGIN_NAME, toolIds: ["cursor"], projectRoot: PROJECT_ROOT }) + ).rejects.toThrow(/edited.*hooks|hooks.*edited/); + expect(fs.getFile(HOOKS_PATH)).toContain("--user-flag"); + expect(fs.has(OTHER_SCRIPT_PATH)).toBe(true); + expect(repo.getCurrent()?.getPlugins("cursor")).toHaveLength(2); + }); + + it("does not delete an untracked user script just because it shares a plugin directory", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + await installPlugin(fs, manifest, PLUGIN_NAME); + const userScript = join(PROJECT_ROOT, ".cursor", "hooks", PLUGIN_NAME, "user.js"); + fs.setFile(userScript, "user owned"); + const repo = new InMemoryManifestRepository(manifest); + const remove = new PluginRemoveUseCase(fs, repo, new CLIOutput(false), new Map()); + await remove.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + }); + expect(fs.has(SCRIPT_PATH)).toBe(false); + expect(fs.getFile(userScript)).toBe("user owned"); + }); }); diff --git a/cli/tests/contexts/framework/application/framework/translator/remove-plugin-opencode-mcp.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/remove-plugin-opencode-mcp.integration.test.ts index da426f411..296e0a39d 100644 --- a/cli/tests/contexts/framework/application/framework/translator/remove-plugin-opencode-mcp.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/remove-plugin-opencode-mcp.integration.test.ts @@ -148,4 +148,44 @@ describe("remove opencode plugin: unmerge MCP entries (Phase 5)", () => { }) ).resolves.not.toThrow(); }); + + it("refuses reinstall when a previously contributed MCP server was user-edited", async () => { + const fs = new InMemoryFileAdapter(); + const translator = new ModeBFlatMaterializationTranslator( + fs, + new DeterministicHasher(), + () => STUB_HOME + ); + const manifest = Manifest.create(); + manifest.addTool("opencode", "test", []); + await translator.addPlugin( + buildDist(), + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined + ); + const prior = manifest.getPlugins("opencode")[0].mcpEntries; + const config = JSON.parse(fs.getFile(OPENCODE_JSON) ?? "null") as { + mcp: Record; + }; + const name = [...prior.keys()][0]; + config.mcp[name] = USER_SERVER; + const edited = JSON.stringify(config); + fs.setFile(OPENCODE_JSON, edited); + manifest.removePlugin("opencode", PLUGIN_NAME); + await expect( + translator.addPlugin( + buildDist(), + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined, + prior + ) + ).rejects.toThrow(/edited.*MCP|MCP.*edited/); + expect(fs.getFile(OPENCODE_JSON)).toBe(edited); + }); }); diff --git a/cli/tests/contexts/framework/application/init-use-case.unit.test.ts b/cli/tests/contexts/framework/application/init-use-case.unit.test.ts index 90237cb9c..cff050b3c 100644 --- a/cli/tests/contexts/framework/application/init-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/init-use-case.unit.test.ts @@ -23,6 +23,8 @@ describe("init", () => { const manifest = await deps.manifestRepo.load(); expect(manifest).not.toBeNull(); + expect(result.manifest.toJSON()).toEqual(manifest?.toJSON()); + expect(deps.fs.getFile(join(PROJECT_ROOT, ".gitignore"))).toContain(".aidd/cache/"); const tools = manifest?.getInstalledToolIds() ?? []; expect(tools).toHaveLength(0); }); @@ -133,6 +135,19 @@ describe("init", () => { }); describe("--force", () => { + it("does not create or alter gitignore while reusing an existing manifest", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await deps.fs.writeFile(join(PROJECT_ROOT, ".gitignore"), "# User-owned\n"); + const before = (await deps.manifestRepo.load())?.toJSON(); + const result = await new InitUseCase(deps.fs, deps.manifestRepo).execute({ + projectRoot: PROJECT_ROOT, + force: true, + }); + expect(result.manifest.toJSON()).toEqual(before); + expect(deps.fs.getFile(join(PROJECT_ROOT, ".gitignore"))).toBe("# User-owned\n"); + }); + it("preserves existing manifest tools when --force reinitializes", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initProject(deps, PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts index e7f661ad4..3584a5b41 100644 --- a/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts @@ -5,6 +5,7 @@ import { Manifest } from "../../../../../src/contexts/framework/domain/manifest. import { SettingsCapability } from "../../../../../src/contexts/tools/domain/capabilities/settings-capability.js"; import { cursor } from "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { registerTool } from "../../../../../src/contexts/tools/domain/registry.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; import { extractMergeEntries } from "../../../../../src/kernel/merge.js"; import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; import { @@ -30,6 +31,37 @@ function buildUseCase( } describe("InstallRuntimeConfigUseCase", () => { + it("writes a text config asset verbatim and records its content hash without warnings", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + const content = '{ "custom": true }\n'; + const assets = new StubAssetProvider({ "claude/settings.json": content }, deps.assetProvider); + + const result = await buildUseCase(deps, assets).execute({ + toolId: "claude", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + expect(deps.fs.getFile(join(PROJECT_ROOT, ".claude/settings.json"))).toBe(content); + expect(result.files).toStrictEqual([ + new InstallationFile({ + relativePath: ".claude/settings.json", + content, + hash: deps.hasher.hash(content), + }), + ]); + expect(result.fileCount).toBe(1); + expect(result.skipped).toBe(false); + expect(result.warnings).toStrictEqual([]); + expect(manifest.getToolFiles("claude")).toStrictEqual([ + { relativePath: ".claude/settings.json", hash: deps.hasher.hash(content) }, + ]); + }); + it("writes config on fresh install", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initProject(deps, PROJECT_ROOT); @@ -157,6 +189,83 @@ describe("InstallRuntimeConfigUseCase", () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(".claude/settings.json")); }); + it("writes Kilo's project-local JSONC config on a fresh install", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + + await buildUseCase(deps).execute({ + toolId: "kilo", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + expect(deps.fs.has(join(PROJECT_ROOT, ".kilo/kilo.jsonc"))).toBe(true); + expect(deps.fs.has(join(PROJECT_ROOT, "kilo.json"))).toBe(false); + }); + + it("reuses Kilo's existing root JSONC config instead of creating a project-local config", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await deps.fs.writeFile(join(PROJECT_ROOT, "kilo.jsonc"), '{"user": true}'); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + + await buildUseCase(deps).execute({ + toolId: "kilo", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + expect(deps.fs.has(join(PROJECT_ROOT, "kilo.json"))).toBe(false); + expect(deps.fs.getFile(join(PROJECT_ROOT, "kilo.jsonc"))).toBe('{"user": true}'); + expect(deps.fs.has(join(PROJECT_ROOT, ".kilo/kilo.jsonc"))).toBe(false); + }); + + it.each([ + ["opencode", "opencode.json"], + ["kilo", ".kilo/kilo.jsonc"], + ] as const)("preserves %s MCP entries when updating its runtime config", async (toolId, path) => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + + await buildUseCase(deps).execute({ + toolId, + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + const configPath = join(PROJECT_ROOT, path); + const config = JSON.parse(await deps.fs.readFile(configPath)) as Record; + config.mcp = { context: { type: "local", command: "node", args: ["server.js"] } }; + const merged = JSON.stringify(config, null, 2); + await deps.fs.writeFile(configPath, merged); + manifest.updateTrackedFileHash(toolId, path, deps.hasher.hash(merged)); + + await buildUseCase(deps).execute({ + toolId, + projectRoot: PROJECT_ROOT, + manifest, + force: true, + version: "1.0.0", + }); + + const updated = JSON.parse(await deps.fs.readFile(configPath)) as { + mcp: Record; + }; + expect(updated.mcp.context).toStrictEqual({ + type: "local", + command: "node", + args: ["server.js"], + }); + }); + describe("copilot requiresTool gate", () => { it("does not create .vscode/settings.json when vscode is not installed", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); @@ -246,6 +355,38 @@ describe("InstallRuntimeConfigUseCase", () => { registerTool(cursor); }); + it("merges a single declared settings capability and records its keys", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + registerTool({ ...cursor, capabilities: { ...cursor.capabilities, settings: inline } }); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + deps.fs.setFile(join(PROJECT_ROOT, ".cursor/aidd-static.json"), '{"user.setting": 7}'); + + const result = await buildUseCase(deps).execute({ + toolId: "cursor", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + expect( + JSON.parse(deps.fs.getFile(join(PROJECT_ROOT, ".cursor/aidd-static.json")) ?? "null") + ).toStrictEqual({ "user.setting": 7, static: true }); + expect(result.files.map((file) => file.relativePath)).toStrictEqual([ + ".cursor/settings.json", + ".cursor/aidd-static.json", + ]); + expect(result.warnings).toStrictEqual([]); + expect(manifest.getMergeFiles("cursor")).toStrictEqual([ + { + relativePath: ".cursor/aidd-static.json", + sectionKey: null, + entries: extractMergeEntries('{"user.setting":7,"static":true}', null, deps.hasher), + }, + ]); + }); + it("writes inline content and passes over a capability that only consumes", async () => { registerCursorWith([inline, consumesOnly]); const deps = await buildUnitDeps(PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/application/list-installed-rules-use-case.unit.test.ts b/cli/tests/contexts/framework/application/list-installed-rules-use-case.unit.test.ts index c4755378a..d483673b4 100644 --- a/cli/tests/contexts/framework/application/list-installed-rules-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/list-installed-rules-use-case.unit.test.ts @@ -6,6 +6,7 @@ import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { ListInstalledRulesUseCase } from "../../../../src/contexts/framework/application/list-installed-rules-use-case.js"; import type { FileReader } from "../../../../src/kernel/ports/file-reader.js"; @@ -40,6 +41,7 @@ describe("ListInstalledRulesUseCase — every tool's installed rules, in one ans [at(".cursor/rules/1-naming.mdc")]: "---\n---\n", [at(".github/instructions/01-naming.instructions.md")]: "---\n---\n", [at(".codex/rules/1-naming.md")]: "---\n---\n", + [at(".kilo/rules/1-naming.md")]: "---\n---\n", [at(".opencode/rules/1-naming.md")]: "---\n---\n", }) ); @@ -51,6 +53,7 @@ describe("ListInstalledRulesUseCase — every tool's installed rules, in one ans "codex", "copilot", "cursor", + "kilo", "opencode", ]); }); diff --git a/cli/tests/contexts/framework/application/ownership/native-host-registration-gate.unit.test.ts b/cli/tests/contexts/framework/application/ownership/native-host-registration-gate.unit.test.ts new file mode 100644 index 000000000..288f3e076 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/native-host-registration-gate.unit.test.ts @@ -0,0 +1,301 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { describe, expect, it } from "vitest"; +import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; +import type { NativeRegistrations } from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import type { NativePluginActivator } from "../../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; + +const REF = "test-plugin@real-catalog"; +const HOST = "real-catalog"; +const claim = { ref: REF, dependents: [] }; +const registrations: NativeRegistrations = { + binary: "copilot", + marketplaces: [{ alias: "alias", hostName: HOST }], + pluginRefs: [REF], + pluginClaims: [claim], +}; + +describe("native host registration gate", () => { + it.each([undefined, { ...registrations, marketplaces: [{ alias: "other", hostName: "other" }] }])( + "requires a canonical catalogue matching the exact target ref", + async (recorded) => { + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "/registry", + refs: new Map([[REF, { enabled: true }]]), + }), + }, + ], + ]) + ); + await expect(gate.requireTargetedUpdate("copilot", claim, "/A", recorded)).rejects.toThrow( + `copilot: ref '${REF}' has no canonical catalogue source proof; update refused.` + ); + expect(activator.updatedPlugins).toEqual([]); + } + ); + + it.each(["update", "removal"])( + "refuses %s when the canonical catalogue is absent on the host", + async (operation) => { + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "/registry", + refs: new Map([[REF, { enabled: true }]]), + }), + }, + ], + ]), + new Map([ + ["copilot", { read: async () => ({ location: "/catalogue", entries: new Map() }) }], + ]) + ); + const promise = + operation === "update" + ? gate.requireTargetedUpdate("copilot", claim, "/A", registrations) + : gate.planMarketplaceRemoval( + "copilot", + registrations, + registrations.marketplaces[0], + "/A" + ); + await expect(promise).rejects.toThrow("copilot: catalogue source unproven."); + expect(activator.updatedPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + } + ); + + it("plans removal of an owned empty catalogue without inventing plugin claims", async () => { + const registration = { + alias: "alias", + hostName: HOST, + provenance: { kind: "registry" as const, source: "/owned/source" }, + }; + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([["copilot", { read: async () => ({ location: "/registry", refs: new Map() }) }]]), + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "/catalogue", + entries: new Map([[HOST, registration.provenance]]), + }), + }, + ], + ]) + ); + const plan = await gate.planMarketplaceRemoval( + "copilot", + { binary: "copilot", marketplaces: [registration], pluginRefs: [] }, + registration, + "/A" + ); + expect(plan.refs).toEqual([]); + expect(plan.scopes).toEqual(new Map()); + expect(plan.activator).toBe(activator); + }); + + const provenCodex = { + binary: "codex", + marketplaces: [ + { + alias: "alias", + hostName: HOST, + provenance: { + kind: "effective-list" as const, + root: "/home/.codex/plugins/marketplaces/real-catalog", + sourceType: "local", + source: "/project-a/.aidd/cache/built", + }, + }, + ], + pluginRefs: [REF], + pluginClaims: [claim], + } satisfies NativeRegistrations; + const hostSource = (source: string): NativeMarketplaceSourceReader => ({ + read: async () => ({ + location: "codex plugin marketplace list --json", + entries: new Map([[HOST, { ...provenCodex.marketplaces[0].provenance, source }]]), + }), + }); + const reader = (refs?: ReadonlyMap) => + ({ + read: async () => ({ location: "/host/registry", refs }), + }) satisfies HostPluginRegistryReader; + const enabled = () => new Map([[REF, { enabled: true, scope: "user" as const }]]); + + it("refuses a Codex removal when a stale canonical claim points at a foreign current source", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["codex", activator]]), + new Map([["codex", reader(enabled())]]), + new Map([["codex", hostSource("/foreign/catalog")]]) + ); + + await expect( + gate.planMarketplaceRemoval("codex", provenCodex, provenCodex.marketplaces[0], "/project-b") + ).rejects.toThrow(/current host source differs.*reconcile manually/); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.uninstalledPlugins).toEqual([]); + }); + + it("refuses a legacy Codex claim without exact source even if refs are enabled", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["codex", activator]]), + new Map([["codex", reader(enabled())]]), + new Map([["codex", hostSource(provenCodex.marketplaces[0].provenance.source)]]) + ); + + await expect( + gate.planMarketplaceRemoval( + "codex", + { ...provenCodex, marketplaces: [{ alias: "alias", hostName: HOST }] }, + { alias: "alias", hostName: HOST }, + "/project-b" + ) + ).rejects.toThrow(/legacy claim has no source proof/); + expect(activator.removedMarketplaces).toEqual([]); + }); + + it("requires the verified target-update verb before contacting a Codex host", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["codex", activator]]), + new Map([["codex", reader(enabled())]]) + ); + await expect(gate.requireTargetedUpdate("codex", claim, "/A")).rejects.toThrow( + /does not support targeted native plugin update/ + ); + expect(activator.updatedPlugins).toEqual([]); + }); + + it("requires each independent Copilot update capability and keeps the claim on refusal", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const withoutUpdate: NativePluginActivator = { + isAvailable: () => true, + addMarketplace: (source, scope) => activator.addMarketplace(source, scope), + enablesPlugins: () => activator.enablesPlugins(), + removeMarketplace: (name, scope) => activator.removeMarketplace(name, scope), + registrationState: () => activator.registrationState(), + upgradeMarketplaces: () => activator.upgradeMarketplaces(), + enablePlugin: (ref, scope) => activator.enablePlugin(ref, scope), + uninstallPlugin: (ref, scope) => activator.uninstallPlugin(ref, scope), + }; + for (const gate of [ + new NativeHostRegistrationGate(new Map(), new Map([["copilot", reader(enabled())]])), + new NativeHostRegistrationGate( + new Map([["copilot", new FakeNativePluginActivator({ available: false })]]), + new Map([["copilot", reader(enabled())]]) + ), + new NativeHostRegistrationGate( + new Map([["copilot", withoutUpdate]]), + new Map([["copilot", reader(enabled())]]) + ), + new NativeHostRegistrationGate(new Map([["copilot", activator]]), new Map()), + ]) { + await expect(gate.requireTargetedUpdate("copilot", claim, "/A")).rejects.toThrow( + /without its CLI and readable host registry/ + ); + } + for (const registry of [ + reader(), + reader(new Map()), + reader(new Map([[REF, { enabled: false }]])), + ]) { + const gate = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([["copilot", registry]]) + ); + await expect(gate.requireTargetedUpdate("copilot", claim, "/A")).rejects.toThrow( + /not provably enabled/ + ); + } + const gate = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([["copilot", reader(enabled())]]) + ); + await expect(gate.requireTargetedUpdate("copilot", claim, "/A", registrations)).rejects.toThrow( + /legacy claim has no source proof|source reader unavailable/ + ); + expect(activator.updatedPlugins).toEqual([]); + }); + + it("refuses catalogue removal unless CLI, registry, and every owned ref are proven", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const registration = registrations.marketplaces[0]; + for (const gate of [ + new NativeHostRegistrationGate(new Map(), new Map([["copilot", reader(enabled())]])), + new NativeHostRegistrationGate( + new Map([["copilot", new FakeNativePluginActivator({ available: false })]]), + new Map([["copilot", reader(enabled())]]) + ), + new NativeHostRegistrationGate(new Map([["copilot", activator]]), new Map()), + ]) { + await expect( + gate.planMarketplaceRemoval("copilot", registrations, registration, "/A") + ).rejects.toThrow(/CLI or host registry unavailable/); + } + for (const registry of [ + reader(), + reader(new Map()), + reader(new Map([[REF, { enabled: false }]])), + ]) { + const gate = new NativeHostRegistrationGate( + new Map([["codex", activator]]), + new Map([["codex", registry]]), + new Map([["codex", hostSource(provenCodex.marketplaces[0].provenance.source)]]) + ); + await expect( + gate.planMarketplaceRemoval("codex", provenCodex, provenCodex.marketplaces[0], "/A") + ).rejects.toThrow(/host plugin registry|still enabled/); + } + }); + + it("does not adopt a foreign same-catalogue ref, and returns only exact owned host scopes", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const host = new Map(enabled()); + host.set("foreign@real-catalog", { enabled: true, scope: "project" }); + const gate = new NativeHostRegistrationGate( + new Map([["codex", activator]]), + new Map([["codex", reader(host)]]), + new Map([["codex", hostSource(provenCodex.marketplaces[0].provenance.source)]]) + ); + await expect( + gate.planMarketplaceRemoval("codex", provenCodex, provenCodex.marketplaces[0], "/A") + ).rejects.toThrow(/foreign host ref 'foreign@real-catalog'/); + const safe = new NativeHostRegistrationGate( + new Map([["codex", activator]]), + new Map([["codex", reader(enabled())]]), + new Map([["codex", hostSource(provenCodex.marketplaces[0].provenance.source)]]) + ); + expect( + await safe.planMarketplaceRemoval("codex", provenCodex, provenCodex.marketplaces[0], "/A") + ).toMatchObject({ activator, refs: [claim] }); + const plan = await safe.planMarketplaceRemoval( + "codex", + provenCodex, + provenCodex.marketplaces[0], + "/A" + ); + expect(plan.scopes.get(REF)).toBe("user"); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts b/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts new file mode 100644 index 000000000..64e82c173 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { + assertNoForeignNativeRefs, + inspectNativeMarketplaceSource, +} from "../../../../../src/contexts/framework/application/ownership/native-marketplace-source-proof.js"; +import type { NativeMarketplaceRegistration } from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceReader, +} from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; + +const SOURCE: NativeMarketplaceSource = { + kind: "effective-list", + root: "/home/.codex/plugins/marketplaces/real-catalog", + sourceType: "local", + source: "/project-a/.aidd/cache/built", +}; +const claim: NativeMarketplaceRegistration = { + alias: "local-alias", + hostName: "real-catalog", + provenance: SOURCE, +}; + +function reader( + entries?: ReadonlyMap +): NativeMarketplaceSourceReader { + return { read: async () => ({ location: "codex marketplace list --json", entries }) }; +} + +describe("current native marketplace source proof", () => { + it.each([new Error("source permission denied"), "source permission denied"])( + "reports host source read failures as unproven without throwing", + async (error) => { + const sourceReader: NativeMarketplaceSourceReader = { + read: async () => { + throw error; + }, + }; + expect(await inspectNativeMarketplaceSource(sourceReader, "/project-b", claim)).toEqual({ + status: "unproven", + reason: + "Catalogue 'real-catalog': host source read failed (source permission denied); reconcile manually.", + }); + } + ); + + it.each([ + { location: "/host/catalogue", unreadable: "permission denied", reason: "permission denied" }, + { location: "/host/catalogue", reason: "host source unreadable at /host/catalogue" }, + ])( + "reports the exact unreadable host source diagnostic", + async ({ location, unreadable, reason }) => { + expect( + await inspectNativeMarketplaceSource( + { read: async () => ({ location, unreadable }) }, + "/project-b", + claim + ) + ).toEqual({ + status: "unproven", + reason: `Catalogue 'real-catalog': ${reason}; reconcile manually.`, + }); + } + ); + + it("refuses foreign same-catalogue refs even when a canonical owned ref is enabled", async () => { + let requestedRoot = ""; + const host: HostPluginRegistryReader = { + read: async (projectRoot) => { + requestedRoot = projectRoot; + return { + location: "host plugins", + refs: new Map([ + ["mine@real-catalog", { enabled: true }], + ["foreign@real-catalog", { enabled: true }], + ["other@other-catalog", { enabled: true }], + ]), + }; + }, + }; + await expect( + assertNoForeignNativeRefs(host, "real-catalog", new Set(["mine@real-catalog"]), "/project-b") + ).rejects.toThrow(/foreign host ref 'foreign@real-catalog'/); + expect(requestedRoot).toBe("/project-b"); + }); + + it("fails closed on unreadable or missing host plugin readers, but accepts explicit absence", async () => { + await expect( + assertNoForeignNativeRefs(undefined, "real-catalog", new Set(), "/project-b") + ).rejects.toThrow(/reader unavailable/); + await expect( + assertNoForeignNativeRefs( + { read: async () => ({ location: "host plugins", unreadable: "EACCES" }) }, + "real-catalog", + new Set(), + "/project-b" + ) + ).rejects.toThrow(/EACCES/); + await expect( + assertNoForeignNativeRefs( + { + read: async () => { + throw new Error("EACCES"); + }, + }, + "real-catalog", + new Set(), + "/project-b" + ) + ).rejects.toThrow(/real-catalog.*host plugin registry read failed.*EACCES/); + expect( + await assertNoForeignNativeRefs( + { read: async () => ({ location: "host plugins", absent: true }) }, + "real-catalog", + new Set(), + "/project-b" + ) + ).toEqual(new Map()); + }); + + it("returns exact host ref states when all same-catalogue refs are AIDD-owned", async () => { + const refs = new Map([ + ["mine@real-catalog", { enabled: true, scope: "user" as const }], + ["other@other-catalog", { enabled: true }], + ]); + expect( + await assertNoForeignNativeRefs( + { read: async () => ({ location: "host plugins", refs }) }, + "real-catalog", + new Set(["mine@real-catalog"]), + "/project-b" + ) + ).toEqual(refs); + }); + it("treats a readable missing name as fresh absence, not ownership", async () => { + const result = await inspectNativeMarketplaceSource(reader(new Map()), "/project-b", claim); + + expect(result.status).toBe("absent"); + }); + + it("refuses a named foreign source despite an old canonical same-name claim", async () => { + for (const changed of [ + { ...SOURCE, source: "/foreign/catalog" }, + { ...SOURCE, root: "/foreign/cache" }, + { ...SOURCE, sourceType: "git" }, + ]) { + const result = await inspectNativeMarketplaceSource( + reader(new Map([[claim.hostName, changed]])), + "/project-b", + claim + ); + + expect(result.status).toBe("unproven"); + expect(result.reason).toContain("real-catalog"); + expect(result.reason).toContain("current host source differs"); + } + }); + + it("refuses legacy claims, unknown named sources, and unreadable hosts", async () => { + const legacy = { alias: claim.alias, hostName: claim.hostName }; + for (const [sourceReader, registration] of [ + [reader(new Map([[claim.hostName, SOURCE]])), legacy], + [reader(new Map([[claim.hostName, null]])), claim], + [reader(), claim], + [undefined, claim], + ] as const) { + const result = await inspectNativeMarketplaceSource(sourceReader, "/project-b", registration); + + expect(result.status).toBe("unproven"); + expect(result.reason).toContain("real-catalog"); + } + }); + + it("owns only an exact source measured for the requesting project", async () => { + const result = await inspectNativeMarketplaceSource( + reader(new Map([[claim.hostName, SOURCE]])), + "/project-b", + claim + ); + + expect(result).toMatchObject({ status: "owned", current: SOURCE }); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/native-plugin-ownership.integration.test.ts b/cli/tests/contexts/framework/application/ownership/native-plugin-ownership.integration.test.ts new file mode 100644 index 000000000..ac0f9018a --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/native-plugin-ownership.integration.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { + detachNativePluginRefs, + isMachineOwnedNativeRef, +} from "../../../../../src/contexts/framework/application/ownership/native-plugin-ownership.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +const TARGET = "sample-plugin@real-catalog"; +const OTHER = "sample-plugin@other-catalog"; + +function fixture() { + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { alias: "real-catalog", hostName: "real-catalog" }, + { alias: "other-catalog", hostName: "other-catalog" }, + ], + pluginRefs: [TARGET, OTHER], + pluginClaims: [ + { ref: TARGET, dependents: ["/A", "/B"] }, + { ref: OTHER, dependents: ["/A", "/C"] }, + ], + }); + return { repo: new InMemoryManifestRepository(machine), fs: new InMemoryFileAdapter() }; +} + +describe("one project's exact native host ref claim", () => { + it("recognizes only canonical machine claims, not another catalogue or missing manifests", async () => { + const f = fixture(); + expect(await isMachineOwnedNativeRef(f.repo, "codex", TARGET)).toBe(true); + expect(await isMachineOwnedNativeRef(f.repo, "codex", "foreign@real-catalog")).toBe(false); + expect(await isMachineOwnedNativeRef(f.repo, "copilot", TARGET)).toBe(false); + expect(await isMachineOwnedNativeRef(new InMemoryManifestRepository(), "codex", TARGET)).toBe( + false + ); + expect(await isMachineOwnedNativeRef(undefined, "codex", TARGET)).toBe(false); + }); + + it("detaches A's realpath only from the exact ref, preserving B and other catalogue claims", async () => { + const f = fixture(); + f.fs.setSymlink("/link-to-A", "/A"); + await detachNativePluginRefs(f.repo, f.fs, "/link-to-A", new Map([["codex", [TARGET]]])); + expect(f.repo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: TARGET, dependents: ["/B"] }, + { ref: OTHER, dependents: ["/A", "/C"] }, + ]); + expect(f.repo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + TARGET, + OTHER, + ]); + expect(f.repo.saveCount).toBe(1); + }); + + it("does not invent a claim or save for an unrelated project or foreign ref", async () => { + for (const [root, ref] of [ + ["/C", TARGET], + ["/A", "foreign@real-catalog"], + ] as const) { + const f = fixture(); + await detachNativePluginRefs(f.repo, f.fs, root, new Map([["codex", [ref]]])); + expect(f.repo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: TARGET, dependents: ["/A", "/B"] }, + { ref: OTHER, dependents: ["/A", "/C"] }, + ]); + expect(f.repo.saveCount).toBe(0); + } + }); + + it("does not act with no repo, no refs, no manifest, or no host claim record", async () => { + const fs = new InMemoryFileAdapter(); + await detachNativePluginRefs(undefined, fs, "/A", new Map([["codex", [TARGET]]])); + const f = fixture(); + await detachNativePluginRefs(f.repo, fs, "/A", new Map()); + await detachNativePluginRefs( + new InMemoryManifestRepository(), + fs, + "/A", + new Map([["codex", [TARGET]]]) + ); + await detachNativePluginRefs(f.repo, fs, "/A", new Map([["copilot", [TARGET]]])); + expect(f.repo.saveCount).toBe(0); + }); + + it("holds exclusive access while loading the latest machine claims", async () => { + const f = fixture(); + let entered = false; + const repo = { + path: f.repo.path, + load: () => { + expect(entered).toBe(true); + return f.repo.load(); + }, + save: (manifest: Manifest) => f.repo.save(manifest), + delete: () => f.repo.delete(), + withExclusiveAccess: async (action: () => Promise): Promise => { + entered = true; + try { + return await action(); + } finally { + entered = false; + } + }, + }; + await detachNativePluginRefs(repo, f.fs, "/A", new Map([["codex", [TARGET]]])); + expect( + f.repo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims?.[0]?.dependents + ).toEqual(["/B"]); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/project-path-boundary.unit.test.ts b/cli/tests/contexts/framework/application/ownership/project-path-boundary.unit.test.ts new file mode 100644 index 000000000..78be76c19 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/project-path-boundary.unit.test.ts @@ -0,0 +1,78 @@ +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { assertProjectPathWithinRoot } from "../../../../../src/contexts/framework/application/ownership/project-path-boundary.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; + +const ROOT = resolve("/project"); + +describe("project-local mutation boundary", () => { + it.each([ROOT, "/project/../foreign/file", "/project-sibling/file", "/foreign/file"])( + "refuses a path outside the strict project subtree: %s", + async (path) => { + await expect( + assertProjectPathWithinRoot(new FaultingFileAdapter(), ROOT, path) + ).rejects.toThrow(/escapes project root/); + } + ); + + it("allows a new nested file only after resolving its nearest existing project parent", async () => { + const fs = new FaultingFileAdapter(); + for (const path of ["/project/new/deep/file", "/project/new/deep", "/project/new"]) + fs.failOn("realpath", path, errnoError("ENOENT")); + fs.setFile("/project/marker", "project bytes"); + + await expect( + assertProjectPathWithinRoot(fs, ROOT, "/project/new/deep/file") + ).resolves.toBeUndefined(); + expect(fs.getFile("/project/marker")).toBe("project bytes"); + }); + + it("refuses a missing file below a parent resolving outside the project", async () => { + const fs = new FaultingFileAdapter(); + fs.setSymlink("/project/link", "/foreign"); + fs.setFile("/foreign/new/marker", "foreign bytes"); + fs.failOn("realpath", "/project/link/new/file", errnoError("ENOENT")); + + await expect(assertProjectPathWithinRoot(fs, ROOT, "/project/link/new/file")).rejects.toThrow( + /resolves outside project root/ + ); + expect(fs.getFile("/foreign/new/marker")).toBe("foreign bytes"); + }); + + it("propagates an unreadable path instead of treating it as a missing child", async () => { + const fs = new FaultingFileAdapter(); + const error = errnoError("EACCES"); + fs.failOn("realpath", "/project/private/file", error); + + await expect(assertProjectPathWithinRoot(fs, ROOT, "/project/private/file")).rejects.toBe( + error + ); + }); + + it("refuses a project root that disappears while a missing child is being resolved", async () => { + const fs = new FaultingFileAdapter(); + let rootRead = false; + vi.spyOn(fs, "realpath").mockImplementation(async (path) => { + if (path === ROOT && !rootRead) { + rootRead = true; + return ROOT; + } + throw errnoError("ENOENT"); + }); + + await expect(assertProjectPathWithinRoot(fs, ROOT, "/project/new/file")).rejects.toThrow( + /Project root.*disappeared/ + ); + }); + + it("allows a child of a project whose own root is a symlink", async () => { + const fs = new FaultingFileAdapter(); + fs.setSymlink(ROOT, "/canonical-project"); + fs.setFile("/canonical-project/file", "project bytes"); + + await expect(assertProjectPathWithinRoot(fs, ROOT, "/project/file")).resolves.toBeUndefined(); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-marketplace-remove.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-marketplace-remove.integration.test.ts new file mode 100644 index 000000000..8ffe7500e --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-marketplace-remove.integration.test.ts @@ -0,0 +1,620 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + FRAMEWORK_MARKETPLACE_NAME, + Marketplace, +} from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../../../src/contexts/distribution/domain/ports/marketplace-registry.js"; +import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; +import { UserMarketplaceRemoveUseCase } from "../../../../../src/contexts/framework/application/ownership/user-marketplace-remove-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceReader, +} from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const ALIAS = "project-alias"; +const HOST = "real-catalog"; +const REF = `test-plugin@${HOST}`; +const OPTIONS = { name: ALIAS, projectRoot: "/A", autoConfirm: true, scope: "user" as const }; + +function proofFor(): NativeMarketplaceSource { + return { + kind: "effective-list", + root: "/exact/host/root", + sourceType: "github", + source: "ai-driven-dev/framework", + }; +} + +function sourceReader(): NativeMarketplaceSourceReader { + return { + read: async () => ({ + location: "/host/catalogues", + entries: new Map([[HOST, proofFor()]]), + }), + }; +} + +for (const toolId of ["codex"] as const) { + describe(`${toolId} targeted user marketplace removal`, () => { + async function fixture( + refs: ReadonlyMap, + options: { + dependents?: readonly string[]; + available?: boolean; + failOnUninstall?: boolean; + throwOnRemove?: boolean; + } = {} + ) { + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [{ alias: ALIAS, hostName: HOST, provenance: proofFor() }], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [...(options.dependents ?? [])] }], + }); + const repo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: ALIAS, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "user", + addedAt: "2026-09-01T00:00:00.000Z", + }) + ); + const activator = new FakeNativePluginActivator({ + available: options.available ?? true, + failOnUninstall: options.failOnUninstall ? [REF] : [], + throwOnRemove: options.throwOnRemove ?? false, + }); + const reader: HostPluginRegistryReader = { + read: async () => ({ location: "/host/registry", refs }), + }; + const useCase = new UserMarketplaceRemoveUseCase( + new InMemoryFileAdapter(), + repo, + registry, + new NativeHostRegistrationGate( + new Map([[toolId, activator]]), + new Map([[toolId, reader]]), + new Map([[toolId, sourceReader()]]) + ) + ); + return { repo, registry, activator, useCase }; + } + + const enabled = () => new Map([[REF, { enabled: true, scope: "user" as const }]]); + + it("refuses the shared framework catalogue before any registry or host access", async () => { + const f = await fixture(enabled()); + const list = vi.spyOn(f.registry, "list"); + await expect( + f.useCase.execute({ ...OPTIONS, name: FRAMEWORK_MARKETPLACE_NAME }) + ).rejects.toThrow(/shared by every project.*aidd clean --scope user/); + expect(list).not.toHaveBeenCalled(); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.repo.saveCount).toBe(0); + }); + + it("selects the requested user catalogue rather than the first unrelated catalogue", async () => { + const f = await fixture(enabled()); + const target = (await f.registry.list("/A"))[0]; + if (target === undefined) throw new Error("fixture lacks target catalogue"); + await f.registry.delete("/A", ALIAS, "user"); + const other = Marketplace.create({ + name: "other-alias", + source: { kind: "github", repo: "other/catalogue" }, + scope: "user", + addedAt: "2026-09-02T00:00:00.000Z", + }); + await f.registry.save("/A", other); + await f.registry.save("/A", target); + expect((await f.registry.list("/A"))[0]).toEqual(other); + const deletion = vi.spyOn(f.registry, "delete"); + const hostRemoval = vi.spyOn(f.activator, "removeMarketplace"); + + expect(await f.useCase.execute(OPTIONS)).toMatchObject({ + marketplace: { name: ALIAS, scope: "user" }, + removedPluginCount: 1, + }); + expect(deletion).toHaveBeenCalledExactlyOnceWith("/A", ALIAS, "user"); + expect(hostRemoval).toHaveBeenCalledExactlyOnceWith(HOST, "user"); + expect(await f.registry.list("/A")).toEqual([other]); + expect(f.repo.saveCount).toBe(1); + }); + + it("defaults native uninstall to user only when the proven host ref has no scope", async () => { + const f = await fixture(new Map([[REF, { enabled: true }]])); + await f.useCase.execute(OPTIONS); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.activator.uninstalledPluginScopes).toEqual(["user"]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + }); + + it("removes a proven empty catalogue without inventing missing plugin claims", async () => { + const f = await fixture(new Map()); + f.repo.getCurrent()?.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [{ alias: ALIAS, hostName: HOST, provenance: proofFor() }], + pluginRefs: [], + }); + expect(await f.useCase.execute(OPTIONS)).toMatchObject({ removedPluginCount: 0 }); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([HOST]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)).toMatchObject({ + marketplaces: [], + pluginRefs: [], + pluginClaims: [], + }); + expect(await f.registry.list("/A")).toEqual([]); + }); + + it("refuses catalogue deletion when the canonical user ledger is missing", async () => { + const f = await fixture(enabled()); + const before = await f.registry.list("/A"); + await f.repo.delete(); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/no user manifest/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(await f.registry.list("/A")).toEqual(before); + expect(f.repo.saveCount).toBe(0); + }); + + it("refuses B's live claim, including with autoConfirm", async () => { + const f = await fixture(enabled(), { dependents: ["/B"] }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow( + /user-scope marketplace 'project-alias'.*active projects.*\/B/ + ); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect( + f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims?.[0]?.dependents + ).toEqual(["/B"]); + }); + + it("never treats a project-scope catalogue with the same alias as a user-scope target", async () => { + const f = await fixture(enabled()); + await f.registry.save( + "/A", + Marketplace.create({ + name: ALIAS, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "project", + addedAt: "2026-09-02T00:00:00.000Z", + }) + ); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/Marketplace.*not registered/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("rechecks the user catalogue source after taking the machine lock", async () => { + const f = await fixture(enabled()); + const lockedRepo = { + path: f.repo.path, + load: () => f.repo.load(), + save: (manifest: Manifest) => f.repo.save(manifest), + delete: () => f.repo.delete(), + withExclusiveAccess: async (action: () => Promise): Promise => { + await f.registry.save( + "/A", + Marketplace.create({ + name: ALIAS, + source: { kind: "github", repo: "foreign/changed" }, + scope: "user", + addedAt: "2026-09-02T00:00:00.000Z", + }) + ); + return action(); + }, + }; + const useCase = new UserMarketplaceRemoveUseCase( + new InMemoryFileAdapter(), + lockedRepo, + f.registry, + new NativeHostRegistrationGate(new Map([[toolId, f.activator]]), new Map()) + ); + await expect(useCase.execute(OPTIONS)).rejects.toThrow( + /changed while waiting for the machine lock/ + ); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("requires a canonical user manifest before uninstalling any host ref", async () => { + const f = await fixture(enabled()); + const absent = new InMemoryManifestRepository(); + const useCase = new UserMarketplaceRemoveUseCase( + new InMemoryFileAdapter(), + absent, + f.registry, + new NativeHostRegistrationGate(new Map([[toolId, f.activator]]), new Map()) + ); + await expect(useCase.execute(OPTIONS)).rejects.toThrow(/no user manifest/); + expect(f.activator.uninstalledPlugins).toEqual([]); + }); + + it("refuses a foreign ref in the same host catalogue without mutating either ref", async () => { + const refs = new Map([ + ...enabled(), + [`foreign@${HOST}`, { enabled: true, scope: "user" as const }], + ]); + const f = await fixture(refs); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow( + /foreign host ref.*foreign@real-catalog/ + ); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("refuses an unreadable host registry before any host mutation", async () => { + const f = await fixture(enabled()); + const unreadable = new NativeHostRegistrationGate( + new Map([[toolId, f.activator]]), + new Map([ + [ + toolId, + { read: async () => ({ location: "/host/registry", unreadable: "permission denied" }) }, + ], + ]), + new Map([[toolId, sourceReader()]]) + ); + const useCase = new UserMarketplaceRemoveUseCase( + new InMemoryFileAdapter(), + f.repo, + f.registry, + unreadable + ); + await expect(useCase.execute(OPTIONS)).rejects.toThrow(/host plugin registry unreadable/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("refuses an unavailable host CLI", async () => { + const f = await fixture(enabled(), { available: false }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/CLI or host registry unavailable/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("refuses a missing or disabled owned ref", async () => { + for (const refs of [ + new Map(), + new Map([[REF, { enabled: false, scope: "user" as const }]]), + ]) { + const f = await fixture(refs); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/still enabled/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + } + }); + + it("keeps the canonical claim and registry if the host refuses unregister", async () => { + const f = await fixture(enabled(), { failOnUninstall: true }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/not installed/); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeDefined(); + expect(f.activator.removedMarketplaces).toEqual([]); + }); + + it("retains the claim and catalogue record if catalogue removal fails after ref uninstall", async () => { + const f = await fixture(enabled(), { throwOnRemove: true }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/marketplace remove.*failed/); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeDefined(); + }); + + it("retains the machine claim if the local catalogue registry refuses deletion", async () => { + const f = await fixture(enabled()); + const registry: MarketplaceRegistry = { + list: (root) => f.registry.list(root), + save: (root, marketplace) => f.registry.save(root, marketplace), + delete: async () => { + throw new Error("registry delete failed"); + }, + updateLastFetched: (root, name, scope, when) => + f.registry.updateLastFetched(root, name, scope, when), + updateVersion: (root, name, scope, version) => + f.registry.updateVersion(root, name, scope, version), + }; + const useCase = new UserMarketplaceRemoveUseCase( + new InMemoryFileAdapter(), + { + path: f.repo.path, + load: async () => { + const current = await f.repo.load(); + return current === null ? null : Manifest.fromJSON(current.toJSON()); + }, + save: (manifest) => f.repo.save(manifest), + delete: () => f.repo.delete(), + }, + registry, + new NativeHostRegistrationGate( + new Map([[toolId, f.activator]]), + new Map([ + [toolId, { read: async () => ({ location: "/host/registry", refs: enabled() }) }], + ]), + new Map([[toolId, sourceReader()]]) + ) + ); + await expect(useCase.execute(OPTIONS)).rejects.toThrow(/registry delete failed/); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeDefined(); + }); + + it("unregisters the exact ref and real host catalogue after B detaches", async () => { + const f = await fixture(enabled()); + expect(await f.useCase.execute(OPTIONS)).toMatchObject({ removedPluginCount: 1 }); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.activator.uninstalledPluginScopes).toEqual(["user"]); + expect(f.activator.removedMarketplaces).toEqual([HOST]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeUndefined(); + }); + + it("uses the host's recorded ref scope rather than guessing user for native uninstall", async () => { + const f = await fixture(new Map([[REF, { enabled: true, scope: "project" as const }]])); + await f.useCase.execute(OPTIONS); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.activator.uninstalledPluginScopes).toEqual(["project"]); + expect(f.activator.removedMarketplaces).toEqual([HOST]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + }); + + it("preserves a different canonical host catalogue and its dependent ref", async () => { + const f = await fixture(enabled()); + const current = f.repo.getCurrent(); + const registration = current?.getNativeRegistrations(toolId); + expect(registration).toBeDefined(); + if (current === null || registration === undefined) throw new Error("fixture lacks registry"); + current.setNativeRegistrations(toolId, { + ...registration, + marketplaces: [ + ...registration.marketplaces, + { alias: "other-alias", hostName: "other-catalog" }, + ], + pluginRefs: [...registration.pluginRefs, "other-plugin@other-catalog"], + pluginClaims: [ + ...(registration.pluginClaims ?? []), + { ref: "other-plugin@other-catalog", dependents: ["/B"] }, + ], + }); + expect(await f.useCase.execute(OPTIONS)).toMatchObject({ removedPluginCount: 1 }); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.activator.removedMarketplaces).toEqual([HOST]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: "other-plugin@other-catalog", dependents: ["/B"] }, + ]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual([ + "other-plugin@other-catalog", + ]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.marketplaces).toEqual([ + { alias: "other-alias", hostName: "other-catalog" }, + ]); + }); + }); +} + +describe("Copilot 1.0.83 targeted user marketplace removal", () => { + it("refuses an otherwise claimed and enabled catalogue without a verified current source, preserving settings and claims", async () => { + const machine = Manifest.create(); + machine.addTool("copilot", "1.0.83", []); + machine.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [ + { + alias: ALIAS, + hostName: HOST, + provenance: { kind: "registry", source: "/previous/aidd/source" }, + }, + ], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [] }], + }); + const repo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + const marketplace = Marketplace.create({ + name: ALIAS, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "user", + addedAt: "2026-09-01T00:00:00.000Z", + }); + await registry.save("/A", marketplace); + const fs = new InMemoryFileAdapter(); + fs.setFile("/A/copilot-user-note.md", "user bytes"); + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "/host/registry", + refs: new Map([[REF, { enabled: true, scope: "user" as const }]]), + }), + }, + ], + ]) + ); + const useCase = new UserMarketplaceRemoveUseCase(fs, repo, registry, gate); + + await expect(useCase.execute(OPTIONS)).rejects.toThrow(/host source reader unavailable/); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(repo.saveCount).toBe(0); + expect(repo.getCurrent()?.getNativeRegistrations("copilot")?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + expect(repo.getCurrent()?.getNativeRegistrations("copilot")?.marketplaces).toEqual([ + { + alias: ALIAS, + hostName: HOST, + provenance: { kind: "registry", source: "/previous/aidd/source" }, + }, + ]); + expect(await registry.list("/A")).toEqual([marketplace]); + expect(fs.getFile("/A/copilot-user-note.md")).toBe("user bytes"); + }); +}); + +describe("Cursor targeted user marketplace removal", () => { + async function fixture( + options: { claim?: boolean; dependents?: readonly string[]; escape?: boolean } = {} + ) { + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + const path = "sample-plugin/skills/demo/SKILL.md"; + if (options.claim !== false) { + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: { [path]: createHash("md5").update("bytes").digest("hex") }, + scope: "user", + marketplace: ALIAS, + dependents: [...(options.dependents ?? [])], + }) + ); + } + const repo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: ALIAS, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "user", + addedAt: "2026-09-01T00:00:00.000Z", + }) + ); + const fs = new InMemoryFileAdapter(); + const pluginDir = join(homedir(), ".cursor/plugins/local/sample-plugin"); + const foreign = "/foreign/sample-plugin"; + fs.setFile(join(options.escape ? foreign : pluginDir, "skills/demo/SKILL.md"), "bytes"); + if (options.escape) fs.setSymlink(pluginDir, foreign); + const useCase = new UserMarketplaceRemoveUseCase( + fs, + repo, + registry, + new NativeHostRegistrationGate(new Map(), new Map()) + ); + return { repo, registry, fs, useCase, path, pluginDir, foreign }; + } + + it("refuses a foreign user catalogue with no canonical plugin claim", async () => { + const f = await fixture({ claim: false }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/Cannot prove AIDD ownership/); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeDefined(); + expect(f.repo.getCurrent()?.getPlugins("cursor")).toEqual([]); + }); + + it("refuses B's live file claim before deleting any bytes", async () => { + const f = await fixture({ dependents: ["/B"] }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/active projects.*\/B/); + expect(f.fs.getFile(join(f.pluginDir, "skills/demo/SKILL.md"))).toBe("bytes"); + expect(f.repo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + }); + + it("refuses a symlinked plugin directory outside the user boundary", async () => { + const f = await fixture({ escape: true }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/tracked file outside its boundary/); + expect(f.fs.getFile(join(f.foreign, "skills/demo/SKILL.md"))).toBe("bytes"); + expect(f.repo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + }); + + it("does not sweep another catalogue or a project-scoped record from the machine manifest", async () => { + const f = await fixture(); + f.repo.getCurrent()?.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "other-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + marketplace: "other-alias", + dependents: [], + }) + ); + f.repo.getCurrent()?.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "project-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + marketplace: ALIAS, + dependents: [], + }) + ); + expect(await f.useCase.execute(OPTIONS)).toMatchObject({ removedPluginCount: 1 }); + expect( + f.repo + .getCurrent() + ?.getPlugins("cursor") + .map((plugin) => plugin.name) + ).toEqual(["other-plugin", "project-plugin"]); + }); + + it("deletes exactly the owned user files and canonical record", async () => { + const f = await fixture(); + expect(await f.useCase.execute(OPTIONS)).toMatchObject({ removedPluginCount: 1 }); + expect(f.fs.getFile(join(f.pluginDir, "skills/demo/SKILL.md"))).toBeUndefined(); + expect(f.repo.getCurrent()?.getPlugins("cursor")).toEqual([]); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeUndefined(); + }); + + it("refuses to remove a user-edited script and leaves marketplace and machine claim retryable", async () => { + const f = await fixture(); + const path = join(f.pluginDir, "skills/demo/SKILL.md"); + f.fs.setFile(path, "user-edited bytes"); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/edited.*SKILL.md|SKILL.md.*edited/); + expect(f.fs.getFile(path)).toBe("user-edited bytes"); + expect(f.repo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeDefined(); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts new file mode 100644 index 000000000..909d1664a --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts @@ -0,0 +1,90 @@ +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { UserPluginDistributionLoader } from "../../../../../src/contexts/framework/application/ownership/user-plugin-distribution-loader.js"; +import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +function fixture(installedVersion = "0.0.1") { + const fs = new InMemoryFileAdapter(); + const hasher = new DeterministicHasher(); + const logger = new CapturingLogger(); + const content = "# Updated skill"; + const skill = { relativePath: "skills/demo/SKILL.md", content }; + const dist = new PluginDistribution({ + manifest: { name: "sample-plugin", version: "1.0.0" }, + format: "claude", + files: [skill], + components: { commands: [], agents: [], rules: [], skills: [skill], hooks: [], mcp: [] }, + }); + const updater = new UserPluginFileUpdater( + fs, + new UserPluginDistributionLoader(new FixturePluginFetcher(), { read: async () => dist }), + hasher + ); + const plugin = InstalledPlugin.fromMetadata( + "sample-plugin", + installedVersion, + { kind: "local", path: "/source" }, + false, + "user" + ).withDependents(["/A", "/B"]); + const base = join(homedir(), ".cursor/plugins/local"); + return { fs, hasher, logger, updater, plugin, base, content }; +} + +describe("UserPluginFileUpdater public update contract", () => { + it.each(["1.0.0", "2.0.0"])( + "leaves version %s unchanged when no upgrade exists", + async (version) => { + const f = fixture(version); + expect(await f.updater.update(f.plugin, "cursor", "/A", f.logger)).toBeNull(); + expect(await f.fs.fileExists(join(f.base, "sample-plugin/skills/demo/SKILL.md"))).toBe(false); + expect(f.plugin.version).toBe(version); + expect(f.plugin.dependents).toEqual(["/A", "/B"]); + } + ); + + it("applies a planned upgrade through the public convenience method and preserves dependents", async () => { + const f = fixture(); + const result = await f.updater.update(f.plugin, "cursor", "/A", f.logger); + expect(result?.version).toBe("1.0.0"); + expect(result?.scope).toBe("user"); + expect(result?.dependents).toEqual(["/A", "/B"]); + expect(await f.fs.readFile(join(f.base, "sample-plugin/skills/demo/SKILL.md"))).toBe(f.content); + expect(result?.files.get("sample-plugin/skills/demo/SKILL.md")).toBe( + f.hasher.hash(f.content).value + ); + }); + + it("refuses unsafe recorded files with an actionable diagnostic before writes", async () => { + const f = fixture(); + const unsafe = f.plugin.withFiles( + new Map([["../foreign.txt", f.hasher.hash("foreign").value]]) + ); + await f.fs.writeFile(join(f.base, "../foreign.txt"), "foreign"); + await expect(f.updater.update(unsafe, "cursor", "/A", f.logger)).rejects.toThrow( + "cursor: 'sample-plugin' has unsafe recorded files; update refused without dropping its machine claim." + ); + expect(await f.fs.readFile(join(f.base, "../foreign.txt"))).toBe("foreign"); + expect(await f.fs.fileExists(join(f.base, "sample-plugin/skills/demo/SKILL.md"))).toBe(false); + }); + + it("rechecks planned new-file containment when a parent becomes a symlink", async () => { + const f = fixture(); + const plan = await f.updater.planUpdate(f.plugin, "cursor", "/A", f.logger); + if (plan === null) throw new Error("Expected upgrade plan"); + await f.fs.writeFile("/foreign/skills/demo/SKILL.md", "foreign"); + f.fs.setSymlink(join(f.base, "sample-plugin"), "/foreign"); + await expect(f.updater.applyUpdate(plan, f.logger)).rejects.toThrow( + `cursor: 'sample-plugin' directory is not safely inside ${f.base}; update refused.` + ); + expect(await f.fs.readFile("/foreign/skills/demo/SKILL.md")).toBe("foreign"); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-ownership.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-ownership.integration.test.ts new file mode 100644 index 000000000..675c31069 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-ownership.integration.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { detachUserPlugin } from "../../../../../src/contexts/framework/application/ownership/user-plugin-ownership.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +function fixture(scope: "project" | "user" = "user", dependents: readonly string[] = ["/A", "/B"]) { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope, + dependents: [...dependents], + }) + ); + return { repo: new InMemoryManifestRepository(manifest), fs: new InMemoryFileAdapter() }; +} + +describe("one project's canonical user plugin claim", () => { + it("detaches only A's realpath, retaining B and the owned plugin bytes/record", async () => { + const f = fixture(); + f.fs.setSymlink("/link-to-A", "/A"); + await detachUserPlugin(f.repo, f.fs, "cursor", "sample-plugin", "/link-to-A"); + expect(f.repo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/B"]); + expect(f.repo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + expect(f.repo.saveCount).toBe(1); + }); + + it("does not drop B or save for an unrelated project", async () => { + const f = fixture(); + await detachUserPlugin(f.repo, f.fs, "cursor", "sample-plugin", "/C"); + expect(f.repo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/A", "/B"]); + expect(f.repo.saveCount).toBe(0); + }); + + it("does not rewrite a machine record of another name or project scope", async () => { + for (const [scope, name] of [ + ["user", "other-plugin"], + ["project", "sample-plugin"], + ] as const) { + const f = fixture(scope); + await detachUserPlugin(f.repo, f.fs, "cursor", name, "/A"); + expect(f.repo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/A", "/B"]); + expect(f.repo.saveCount).toBe(0); + } + }); + + it("ignores a missing repo or manifest without inventing ownership", async () => { + const fs = new InMemoryFileAdapter(); + await detachUserPlugin(undefined, fs, "cursor", "sample-plugin", "/A"); + const repo = new InMemoryManifestRepository(); + await detachUserPlugin(repo, fs, "cursor", "sample-plugin", "/A"); + expect(repo.getCurrent()).toBeNull(); + expect(repo.saveCount).toBe(0); + }); + + it("loads the latest machine record under exclusive access before detaching", async () => { + const f = fixture(); + let entered = false; + const locked = { + path: f.repo.path, + load: () => { + expect(entered).toBe(true); + return f.repo.load(); + }, + save: (manifest: Manifest) => f.repo.save(manifest), + delete: () => f.repo.delete(), + withExclusiveAccess: async (action: () => Promise): Promise => { + entered = true; + try { + return await action(); + } finally { + entered = false; + } + }, + }; + await detachUserPlugin(locked, f.fs, "cursor", "sample-plugin", "/A"); + expect(f.repo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/B"]); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-remove.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-remove.integration.test.ts new file mode 100644 index 000000000..77b6cedf3 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-remove.integration.test.ts @@ -0,0 +1,417 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; +import { userScopeFilesSafeToDelete } from "../../../../../src/contexts/framework/application/shared/user-scope-plugin-files.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSource } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { resolveHomeDir } from "../../../../../src/kernel/reading/home-dir.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const REF = "test-plugin@real-catalog"; +const OTHER = "test-plugin@other-catalog"; + +function sourceFor(toolId: "codex" | "copilot", catalogue: string): NativeMarketplaceSource { + return toolId === "codex" + ? { + kind: "effective-list", + root: `/host/${catalogue}`, + sourceType: "github", + source: `ai-driven-dev/${catalogue}`, + } + : { kind: "registry", source: `https://github.com/ai-driven-dev/${catalogue}.git` }; +} + +for (const toolId of ["codex", "copilot"] as const) { + describe(`${toolId} targeted user plugin removal`, () => { + function fixture( + options: { + refs?: readonly string[]; + dependents?: readonly string[]; + hostRefs?: ReadonlyMap; + available?: boolean; + failOnUninstall?: boolean; + sourceMismatch?: boolean; + sourceUnavailable?: boolean; + legacy?: boolean; + hostRegistryUnavailable?: boolean; + hostRegistryUnreadable?: boolean; + } = {} + ) { + const refs = options.refs ?? [REF]; + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: refs.map((ref) => ({ + alias: ref.split("@")[1], + hostName: ref.split("@")[1], + ...(options.legacy ? {} : { provenance: sourceFor(toolId, ref.split("@")[1]) }), + })), + pluginRefs: [...refs], + pluginClaims: refs.map((ref) => ({ ref, dependents: [...(options.dependents ?? [])] })), + }); + const repo = new InMemoryManifestRepository(machine); + const fs = new InMemoryFileAdapter(); + const activator = new FakeNativePluginActivator({ + available: options.available ?? true, + failOnUninstall: options.failOnUninstall ? [REF] : [], + }); + const hostRefs = + options.hostRefs ?? + new Map(refs.map((ref) => [ref, { enabled: true, scope: "user" as const }])); + const reader: HostPluginRegistryReader = { + read: async () => + options.hostRegistryUnreadable + ? { location: "/host/registry", unreadable: "invalid host registry" } + : { location: "/host/registry", refs: hostRefs }, + }; + const sources = { + read: async () => ({ + location: "/host/catalogues", + entries: new Map( + refs.map((ref) => { + const catalogue = ref.split("@")[1]; + return [ + catalogue, + options.sourceMismatch && ref === REF + ? sourceFor(toolId, "foreign-source") + : sourceFor(toolId, catalogue), + ] as const; + }) + ), + }), + }; + const remove = new PluginRemoveUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new Map([[toolId, activator]]), + options.hostRegistryUnavailable ? new Map() : new Map([[toolId, reader]]), + undefined, + new InMemoryMarketplaceRegistry(), + repo, + options.sourceUnavailable ? new Map() : new Map([[toolId, sources]]) + ); + return { machine, repo, fs, activator, remove }; + } + + const execute = (remove: PluginRemoveUseCase, pluginName: string) => + remove.execute({ pluginName, toolIds: [toolId], projectRoot: "/A", scope: "user" }); + + it.each([{ hostRegistryUnavailable: true }, { hostRegistryUnreadable: true }])( + "retains all ownership data when the host registry cannot be measured: %j", + async (options) => { + const f = fixture(options); + const before = f.machine.toJSON(); + + await expect(execute(f.remove, REF)).rejects.toThrow(/unavailable|still enabled/); + + expect(f.activator.uninstalledPlugins).toStrictEqual([]); + expect(f.repo.saveCount).toBe(0); + expect(f.machine.toJSON()).toStrictEqual(before); + } + ); + + it.each(["missing", "duplicate"] as const)( + "refuses a %s exact catalogue proof before unregistering", + async (mode) => { + const f = fixture(); + f.machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: + mode === "missing" + ? [] + : [ + { + alias: "first", + hostName: "real-catalog", + provenance: sourceFor(toolId, "real-catalog"), + }, + { + alias: "second", + hostName: "real-catalog", + provenance: sourceFor(toolId, "real-catalog"), + }, + ], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [] }], + }); + const before = f.machine.toJSON(); + + await expect(execute(f.remove, REF)).rejects.toThrow( + /exact canonical catalogue source proof/ + ); + + expect(f.activator.uninstalledPlugins).toStrictEqual([]); + expect(f.repo.saveCount).toBe(0); + expect(f.machine.toJSON()).toStrictEqual(before); + } + ); + + it("reads dependents inside the repository's exclusive-access boundary", async () => { + const f = fixture(); + let acquired = false; + Object.assign(f.repo, { + withExclusiveAccess: async (operation: () => Promise) => { + acquired = true; + f.machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [ + { + alias: "real-catalog", + hostName: "real-catalog", + provenance: sourceFor(toolId, "real-catalog"), + }, + ], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: ["/B"] }], + }); + return operation(); + }, + }); + + await expect(execute(f.remove, REF)).rejects.toThrow(/active projects.*\/B/); + + expect(acquired).toBe(true); + expect(f.activator.uninstalledPlugins).toStrictEqual([]); + expect(f.repo.saveCount).toBe(0); + expect(f.machine.getNativeRegistrations(toolId)?.pluginClaims).toStrictEqual([ + { ref: REF, dependents: ["/B"] }, + ]); + }); + + it("uses user scope when the enabled host ref has no scope label", async () => { + const f = fixture({ hostRefs: new Map([[REF, { enabled: true }]]) }); + + await execute(f.remove, REF); + + expect(f.activator.uninstalledPlugins).toStrictEqual([REF]); + expect(f.activator.uninstalledPluginScopes).toStrictEqual(["user"]); + expect(f.machine.getNativeRegistrations(toolId)?.pluginClaims).toStrictEqual([]); + expect(f.repo.saveCount).toBe(1); + }); + + it("refuses B's live exact claim before contacting the host", async () => { + const f = fixture({ dependents: ["/B"] }); + await expect(execute(f.remove, REF)).rejects.toThrow(/active projects.*\/B/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect( + f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims?.[0]?.dependents + ).toEqual(["/B"]); + }); + + it("requires an exact ref when names collide across catalogues", async () => { + const f = fixture({ refs: [REF, OTHER] }); + await expect(execute(f.remove, "test-plugin")).rejects.toThrow(/multiple catalogues.*exact/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toHaveLength(2); + }); + + it("resolves a bare native plugin name when exactly one owned catalogue matches", async () => { + const f = fixture(); + await execute(f.remove, "test-plugin"); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual([]); + }); + + it("never adopts a foreign preexisting host ref without a canonical claim", async () => { + const f = fixture({ + refs: [], + hostRefs: new Map([[REF, { enabled: true, scope: "user" as const }]]), + }); + await expect(execute(f.remove, REF)).rejects.toThrow(/not installed/i); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + }); + + it("refuses an unavailable host CLI or disabled host ref and retains the claim", async () => { + for (const options of [ + { available: false }, + { hostRefs: new Map([[REF, { enabled: false, scope: "user" as const }]]) }, + { hostRefs: new Map([[OTHER, { enabled: true, scope: "user" as const }]]) }, + ]) { + const f = fixture(options); + await expect(execute(f.remove, REF)).rejects.toThrow(/unavailable|still enabled/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + } + }); + + it("retains the machine claim if the host refuses unregister", async () => { + const f = fixture({ failOnUninstall: true }); + await expect(execute(f.remove, REF)).rejects.toThrow(/not installed/); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("refuses to uninstall an enabled native ref when its host catalogue source changed", async () => { + const f = fixture({ sourceMismatch: true }); + await expect(execute(f.remove, REF)).rejects.toThrow(/source differs|source.*changed/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("refuses no source reader and legacy unproven source instead of trusting an enabled ref", async () => { + for (const options of [{ sourceUnavailable: true }, { legacy: true }]) { + const f = fixture(options); + await expect(execute(f.remove, REF)).rejects.toThrow( + /source reader unavailable|legacy claim/ + ); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + } + }); + + it("unregisters only the exact host ref and removes only its machine projection", async () => { + const f = fixture({ refs: [REF, OTHER] }); + await execute(f.remove, REF); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.activator.uninstalledPluginScopes).toEqual(["user"]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: OTHER, dependents: [] }, + ]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual([OTHER]); + }); + + it("passes the host's actual project-labelled ref scope to uninstall", async () => { + const f = fixture({ + hostRefs: new Map([[REF, { enabled: true, scope: "project" as const }]]), + }); + await execute(f.remove, REF); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.activator.uninstalledPluginScopes).toEqual(["project"]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + }); + }); +} + +describe("Cursor user plugin boundary", () => { + it("removes exactly an AIDD-owned file and its canonical user record", async () => { + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: { + "sample-plugin/plugin.json": createHash("md5").update("owned bytes").digest("hex"), + }, + scope: "user", + dependents: [], + }) + ); + const repo = new InMemoryManifestRepository(machine); + const fs = new InMemoryFileAdapter(); + const path = join(homedir(), ".cursor/plugins/local/sample-plugin/plugin.json"); + fs.setFile(path, "owned bytes"); + const remove = new PluginRemoveUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new Map(), + new Map(), + undefined, + new InMemoryMarketplaceRegistry(), + repo + ); + fs.setFile(path, "user-edited plugin.json"); + await expect( + remove.execute({ + pluginName: "sample-plugin", + toolIds: ["cursor"], + projectRoot: "/A", + scope: "user", + }) + ).rejects.toThrow(/edited.*plugin.json|plugin.json.*edited/); + expect(fs.getFile(path)).toBe("user-edited plugin.json"); + expect(repo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + fs.setFile(path, "owned bytes"); + await remove.execute({ + pluginName: "sample-plugin", + toolIds: ["cursor"], + projectRoot: "/A", + scope: "user", + }); + expect(fs.getFile(path)).toBeUndefined(); + expect(repo.getCurrent()?.getPlugins("cursor")).toEqual([]); + }); + + it("keeps its canonical record and foreign bytes if a tracked file resolves outside the plugin directory", async () => { + const relativePath = "sample-plugin/skills/demo/SKILL.md"; + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: { [relativePath]: "abc" }, + scope: "user", + dependents: [], + }) + ); + const repo = new InMemoryManifestRepository(machine); + const fs = new InMemoryFileAdapter(); + const pluginDir = join(homedir(), ".cursor/plugins/local/sample-plugin"); + const foreignPath = "/foreign/sample-plugin"; + fs.setFile(join(foreignPath, "skills/demo/SKILL.md"), "foreign bytes"); + fs.setSymlink(pluginDir, foreignPath); + const owned = machine.getPlugins("cursor")[0]; + expect(owned.files.size).toBe(1); + expect( + ( + await userScopeFilesSafeToDelete( + fs, + new CapturingLogger(), + owned, + "cursor", + resolveHomeDir() + ) + ).size + ).toBe(0); + const remove = new PluginRemoveUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new Map(), + new Map(), + undefined, + new InMemoryMarketplaceRegistry(), + repo + ); + await expect( + remove.execute({ + pluginName: "sample-plugin", + toolIds: ["cursor"], + projectRoot: "/A", + scope: "user", + }) + ).rejects.toThrow(/tracked file escaped its boundary/); + expect(fs.getFile(join(foreignPath, "skills/demo/SKILL.md"))).toBe("foreign bytes"); + expect(repo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-update.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-update.integration.test.ts new file mode 100644 index 000000000..335fb97e3 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-update.integration.test.ts @@ -0,0 +1,213 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { describe, expect, it } from "vitest"; +import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; +import { UserPluginDistributionLoader } from "../../../../../src/contexts/framework/application/ownership/user-plugin-distribution-loader.js"; +import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { UserPluginUpdateUseCase } from "../../../../../src/contexts/framework/application/ownership/user-plugin-update-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +const REF = "test-plugin@real-catalog"; +const OTHER = "test-plugin@other-catalog"; + +function fixture( + toolId: "codex" | "copilot", + options: { + refs?: readonly string[]; + dependents?: readonly string[]; + available?: boolean; + hostRefs?: ReadonlyMap; + failOnUpdate?: boolean; + } = {} +) { + const refs = options.refs ?? [REF]; + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: refs.map((ref) => ({ + alias: ref.split("@")[1], + hostName: ref.split("@")[1], + provenance: { kind: "registry" as const, source: "/previous/aidd/source" }, + })), + pluginRefs: [...refs], + pluginClaims: refs.map((ref) => ({ ref, dependents: [...(options.dependents ?? [])] })), + }); + const repo = new InMemoryManifestRepository(machine); + const fs = new InMemoryFileAdapter(); + const activator = new FakeNativePluginActivator({ + available: options.available ?? true, + failOnUpdate: options.failOnUpdate ? [REF] : [], + }); + const hostRefs = + options.hostRefs ?? + new Map(refs.map((ref) => [ref, { enabled: true, scope: "user" as const }])); + const reader: HostPluginRegistryReader = { + read: async () => ({ location: "/host/registry", refs: hostRefs }), + }; + const logger = new CapturingLogger(); + const update = new UserPluginUpdateUseCase( + repo, + new UserPluginFileUpdater( + fs, + new UserPluginDistributionLoader( + new FixturePluginFetcher(), + new PluginDistributionReaderAdapter(fs) + ), + new DeterministicHasher() + ), + logger, + new NativeHostRegistrationGate(new Map([[toolId, activator]]), new Map([[toolId, reader]])) + ); + const execute = (pluginName: string | readonly string[]) => + update.execute({ + pluginNames: typeof pluginName === "string" ? [pluginName] : [...pluginName], + toolIds: [toolId], + projectRoot: "/A", + scope: "user", + }); + const executeAll = () => update.execute({ toolIds: [toolId], projectRoot: "/A", scope: "user" }); + return { repo, activator, logger, execute, executeAll }; +} + +describe("targeted machine native plugin update", () => { + it("Copilot retains exact AIDD claims when its installed binary cannot prove the source", async () => { + const f = fixture("copilot", { refs: [REF, OTHER], dependents: ["/A", "/B"] }); + await expect(f.execute(REF)).rejects.toThrow(/host source reader unavailable/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations("copilot")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + { ref: OTHER, dependents: ["/A", "/B"] }, + ]); + expect(f.repo.saveCount).toBe(0); + }); + + it("refuses ambiguous catalogue names before updating any ref", async () => { + const f = fixture("copilot", { refs: [REF, OTHER] }); + await expect(f.execute("test-plugin")).rejects.toThrow(/Multiple native catalogues.*exact/); + expect(f.activator.updatedPlugins).toEqual([]); + }); + + it("resolves a bare native plugin name but refuses update without current source proof", async () => { + const f = fixture("copilot", { refs: [REF], dependents: ["/B"] }); + await expect(f.execute("test-plugin")).rejects.toThrow(/host source reader unavailable/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations("copilot")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/B"] }, + ]); + }); + + it("does not adopt a foreign enabled host ref without a machine claim", async () => { + const f = fixture("copilot", { refs: [], hostRefs: new Map([[REF, { enabled: true }]]) }); + await expect(f.execute(REF)).rejects.toThrow(/not installed/); + expect(f.activator.updatedPlugins).toEqual([]); + }); + + it("all-target update is a no-op when this machine owns no plugins or native refs", async () => { + const f = fixture("copilot", { refs: [] }); + expect(await f.executeAll()).toEqual([]); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.saveCount).toBe(0); + }); + + it("refuses an unavailable CLI or disabled host ref, retaining canonical claims", async () => { + for (const options of [ + { available: false }, + { hostRefs: new Map([[REF, { enabled: false }]]) }, + ]) { + const f = fixture("copilot", options); + await expect(f.execute(REF)).rejects.toThrow( + /CLI and readable host registry|not provably enabled/ + ); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations("copilot")?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + } + }); + + it("Codex refuses targeted update because its verified binary has no update verb", async () => { + const f = fixture("codex"); + await expect(f.execute(REF)).rejects.toThrow(/does not support targeted native plugin update/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("does not touch a project-scope record when selected Copilot source is unproved", async () => { + const f = fixture("copilot", { refs: [REF, OTHER] }); + const machine = f.repo.getCurrent(); + if (machine === null) throw new Error("fixture missing machine manifest"); + machine.addPlugin( + "copilot", + InstalledPlugin.fromMetadata( + "project-plugin", + "0.0.1", + { kind: "local", path: "/fixture" }, + false, + "project" + ) + ); + await expect(f.execute(REF)).rejects.toThrow(/host source reader unavailable/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(machine.getPlugins("copilot").map((plugin) => plugin.name)).toEqual(["project-plugin"]); + }); + + it("refuses two selected Copilot refs before updating either without current source proof", async () => { + const f = fixture("copilot", { refs: [REF, OTHER] }); + await expect(f.execute([REF, OTHER])).rejects.toThrow(/host source reader unavailable/); + expect(f.activator.updatedPlugins).toEqual([]); + }); + + it("preflights every selected host ref before the first update", async () => { + const f = fixture("copilot", { + refs: [REF, OTHER], + hostRefs: new Map([ + [REF, { enabled: true, scope: "user" }], + [OTHER, { enabled: false, scope: "user" }], + ]), + }); + await expect(f.execute([REF, OTHER])).rejects.toThrow(/not provably enabled/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.saveCount).toBe(0); + }); + + it("all-target Copilot update refuses an unproved catalogue and preserves project records", async () => { + const f = fixture("copilot"); + const machine = f.repo.getCurrent(); + if (machine === null) throw new Error("fixture missing machine manifest"); + machine.addPlugin( + "copilot", + InstalledPlugin.fromMetadata( + "project-plugin", + "0.0.1", + { kind: "local", path: "/fixture" }, + false, + "project" + ).withFiles(new Map([["skills/project/SKILL.md", "hash"]])) + ); + await expect(f.executeAll()).rejects.toThrow(/host source reader unavailable/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(machine.getPlugins("copilot").map((plugin) => plugin.name)).toEqual(["project-plugin"]); + }); + + it("does not reach Copilot's update verb when its current source cannot be proven", async () => { + const f = fixture("copilot", { failOnUpdate: true }); + await expect(f.execute(REF)).rejects.toThrow(/host source reader unavailable/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.saveCount).toBe(0); + expect(f.repo.getCurrent()?.getNativeRegistrations("copilot")?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts index 0f5b9a766..0481635ec 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts @@ -1,9 +1,14 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; import type { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { + buildUnitDeps, + initAndInstall, + installTool, +} from "../../../../helpers/ports/build-unit-deps.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; @@ -43,7 +48,8 @@ async function buildOpencodeProject(): Promise<{ deps.hasher, logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); return { deps, logger, useCase }; } @@ -66,6 +72,253 @@ function installedMcpPlugin(deps: Deps): InstalledPlugin | undefined { } describe("PluginAddUseCase and opencode MCP servers", () => { + it("keeps user servers and other config while recording only successfully installed MCP servers", async () => { + const { deps, logger, useCase } = await buildOpencodeProject(); + const owned = { type: "local", command: ["user-server"], enabled: false }; + deps.fs.setFile( + OPENCODE_JSON, + JSON.stringify({ model: "user/model", mcp: { "local-tool": owned } }) + ); + deps.fs.setFile( + join(MCP_PLUGIN_DIR, ".mcp.json"), + JSON.stringify({ + mcpServers: { + "local-tool": { command: "node", args: ["./server.js"] }, + "new-tool": { command: "node", args: ["./new.js"], env: { MODE: "plugin" } }, + }, + }) + ); + + await addLocal(useCase, MCP_PLUGIN_DIR); + + const config = JSON.parse(deps.fs.getFile(OPENCODE_JSON) ?? "null"); + expect(config.model).toBe("user/model"); + expect(config.mcp["local-tool"]).toStrictEqual(owned); + expect(config.mcp["new-tool"]).toStrictEqual({ + type: "local", + command: ["node", "./new.js"], + environment: { MODE: "plugin" }, + enabled: true, + }); + expect([...(installedMcpPlugin(deps)?.mcpEntries.keys() ?? [])]).toStrictEqual(["new-tool"]); + expect(logger.warnMessages).toStrictEqual([ + 'Plugin "mcp-plugin": mcp skipped for opencode — local-tool: server already exists in opencode.json (user-owned); plugin entry skipped', + ]); + }); + + it("uses the selected Cursor plugin's hook provenance when another hook plugin was installed first", async () => { + const { deps, useCase } = await buildOpencodeProject(); + await installTool(deps, PROJECT_ROOT, "cursor"); + const otherDir = "/plugins/other-hook-plugin"; + for (const [name, dir] of [ + ["other-hook-plugin", otherDir], + ["mcp-plugin", MCP_PLUGIN_DIR], + ]) { + deps.fs.setFile( + join(dir, ".claude-plugin/plugin.json"), + JSON.stringify({ name, version: "1.0.0" }) + ); + deps.fs.setFile(join(dir, "skills/demo/SKILL.md"), "# Demo"); + deps.fs.setFile(join(dir, "hooks/pre.js"), `${name} original script`); + deps.fs.setFile( + join(dir, "hooks/hooks.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { hooks: [{ type: "command", command: `node \${CLAUDE_PLUGIN_ROOT}/hooks/pre.js` }] }, + ], + }, + }) + ); + await useCase.execute({ + source: { kind: "local", path: dir }, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + } + const otherBefore = deps.manifestRepo + .getCurrent() + ?.getPlugins("cursor") + .find((p) => p.name === "other-hook-plugin") + ?.toJSON(); + deps.fs.setFile(join(MCP_PLUGIN_DIR, "hooks/pre.js"), "mcp-plugin updated script"); + + await useCase.execute({ + source: { kind: "local", path: MCP_PLUGIN_DIR }, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + interactive: false, + replace: true, + }); + + const hooks = JSON.parse(deps.fs.getFile(join(PROJECT_ROOT, ".cursor/hooks.json")) ?? "null"); + expect(hooks.hooks.preToolUse.map((hook: { command: string }) => hook.command)).toStrictEqual([ + "node ./.cursor/hooks/other-hook-plugin/pre.js", + "node ./.cursor/hooks/mcp-plugin/pre.js", + ]); + expect(deps.fs.getFile(join(PROJECT_ROOT, ".cursor/hooks/mcp-plugin/pre.js"))).toBe( + "mcp-plugin updated script" + ); + expect(deps.fs.getFile(join(PROJECT_ROOT, ".cursor/hooks/other-hook-plugin/pre.js"))).toBe( + "other-hook-plugin original script" + ); + expect( + deps.manifestRepo + .getCurrent() + ?.getPlugins("cursor") + .find((p) => p.name === "other-hook-plugin") + ?.toJSON() + ).toStrictEqual(otherBefore); + expect( + deps.manifestRepo + .getCurrent() + ?.getPlugins("cursor") + .find((p) => p.name === "mcp-plugin")?.projectHooks?.scripts.size + ).toBe(1); + }); + + it.each([undefined, "local-marketplace"])( + "explains hook trust after a Codex install via %s", + async (marketplace) => { + const { deps, logger, useCase } = await buildOpencodeProject(); + await installTool(deps, PROJECT_ROOT, "codex"); + deps.fs.setFile(join(MCP_PLUGIN_DIR, "hooks/pre.js"), "console.log('hook');"); + deps.fs.setFile( + join(MCP_PLUGIN_DIR, "hooks/hooks.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { hooks: [{ type: "command", command: `node \${CLAUDE_PLUGIN_ROOT}/hooks/pre.js` }] }, + ], + }, + }) + ); + + await useCase.execute({ + source: { kind: "local", path: MCP_PLUGIN_DIR }, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + interactive: false, + marketplace, + }); + + expect(logger.infoMessages).toStrictEqual([ + 'Plugin "mcp-plugin" (codex): Codex will not run this plugin\'s hooks until each one is trusted — approve the prompt once in an interactive session, or pass --dangerously-bypass-hook-trust to codex exec for a headless run. Until then, a session leaves no run journal and nothing says why.', + ]); + expect(logger.warnMessages).toStrictEqual([]); + const installed = deps.manifestRepo + .getCurrent() + ?.getPlugins("codex") + .find((p) => p.name === "mcp-plugin"); + expect(installed).toBeDefined(); + expect(installed?.marketplace).toBe(marketplace); + if (marketplace === undefined) { + expect(deps.fs.getFile(join(PROJECT_ROOT, ".codex/plugins/mcp-plugin/hooks/pre.js"))).toBe( + "console.log('hook');" + ); + expect(installed?.files.size).toBeGreaterThan(0); + } else { + expect(installed?.files.size).toBe(0); + } + } + ); + + it.each(["local", "github marketplace"] as const)( + "refuses %s replacement of an edited MCP contribution before saving or rewriting files", + async (route) => { + const { deps, useCase } = await buildOpencodeProject(); + await seedFromDirectory(deps.fs, EXTRA_PLUGIN_FIXTURE, { useAbsolutePaths: true }); + await addLocal(useCase, EXTRA_PLUGIN_FIXTURE); + await addLocal(useCase, MCP_PLUGIN_DIR); + const installed = installedMcpPlugin(deps); + if (installed === undefined) throw new Error("fixture missing MCP plugin"); + const filesBefore = new Map( + [...installed.files.keys()].map((path) => [path, deps.fs.getFile(join(PROJECT_ROOT, path))]) + ); + const manifestBefore = deps.manifestRepo.getCurrent()?.toJSON(); + const savesBefore = deps.manifestRepo.saveCount; + const userConfig = JSON.stringify({ + mcp: { "local-tool": { type: "local", command: ["user-custom-command"] } }, + }); + deps.fs.setFile(OPENCODE_JSON, userConfig); + if (route === "github marketplace") + await deps.marketplaceRegistry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "project", + addedAt: "2026-05-01T00:00:00.000Z", + }) + ); + + await expect( + useCase.execute({ + source: { kind: "local", path: MCP_PLUGIN_DIR }, + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + interactive: false, + replace: true, + ...(route === "github marketplace" + ? { + marketplace: "aidd-framework", + pluginMetadata: { name: "mcp-plugin", version: "1.0.0", strict: false }, + } + : {}), + }) + ).rejects.toThrow(/edited/); + + expect(deps.fs.getFile(OPENCODE_JSON)).toBe(userConfig); + expect(deps.manifestRepo.saveCount).toBe(savesBefore); + expect(deps.manifestRepo.getCurrent()?.toJSON()).toEqual(manifestBefore); + for (const [path, content] of filesBefore) + expect(deps.fs.getFile(join(PROJECT_ROOT, path))).toBe(content); + } + ); + + it("replaces only the selected plugin while leaving another plugin's user-edited MCP entry untouched", async () => { + const { deps, useCase } = await buildOpencodeProject(); + await addLocal(useCase, MCP_PLUGIN_DIR); + const otherDir = "/plugins/other-mcp-plugin"; + deps.fs.setFile( + join(otherDir, ".claude-plugin/plugin.json"), + JSON.stringify({ + name: "other-mcp-plugin", + version: "1.0.0", + }) + ); + deps.fs.setFile(join(otherDir, "skills/other/SKILL.md"), "# Other skill"); + deps.fs.setFile( + join(otherDir, ".mcp.json"), + JSON.stringify({ + mcpServers: { "other-tool": { command: "node", args: ["./other.js"] } }, + }) + ); + await addLocal(useCase, otherDir); + const current = JSON.parse(deps.fs.getFile(OPENCODE_JSON) ?? "null"); + const edited = { type: "local", command: ["user-other-command"] }; + current.mcp["other-tool"] = edited; + deps.fs.setFile(OPENCODE_JSON, JSON.stringify(current)); + const otherBefore = deps.manifestRepo + .getCurrent() + ?.getPlugins("opencode") + .find((plugin) => plugin.name === "other-mcp-plugin"); + + await addLocal(useCase, MCP_PLUGIN_DIR, true); + + const after = JSON.parse(deps.fs.getFile(OPENCODE_JSON) ?? "null"); + expect(after.mcp["other-tool"]).toEqual(edited); + expect(after.mcp["local-tool"]).toEqual(current.mcp["local-tool"]); + expect( + deps.manifestRepo + .getCurrent() + ?.getPlugins("opencode") + .find((plugin) => plugin.name === "other-mcp-plugin") + ).toEqual(otherBefore); + expect(installedMcpPlugin(deps)?.mcpEntries.size).toBe(1); + }); + it("keeps its own MCP server, without a collision, when re-added with replace", async () => { const { deps, logger, useCase } = await buildOpencodeProject(); await seedFromDirectory(deps.fs, EXTRA_PLUGIN_FIXTURE, { useAbsolutePaths: true }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts index 59afb8ba1..02b79fa4c 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts @@ -31,7 +31,8 @@ describe("PluginAddUseCase skip warnings", () => { deps.hasher, capturingLogger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts index 881db28e2..9a071f111 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts @@ -1,7 +1,10 @@ +import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import type { PluginDistributionReader } from "../../../../../src/contexts/framework/domain/ports/plugin-distribution-reader.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; @@ -13,6 +16,7 @@ import type { Logger } from "../../../../../src/kernel/ports/logger.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; @@ -50,7 +54,8 @@ function buildAddUseCase( deps.hasher, logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); } @@ -80,7 +85,7 @@ function localAdd(toolId: "claude" | "opencode", path = PLUGIN_FIXTURE, replace? }; } -function pluginNames(deps: Deps, toolId: "claude" | "opencode" | "codex"): string[] { +function pluginNames(deps: Deps, toolId: "claude" | "opencode" | "codex" | "cursor"): string[] { return (deps.manifestRepo.getCurrent()?.getPlugins(toolId) ?? []).map((p) => p.name).sort(); } @@ -150,7 +155,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { @@ -193,7 +199,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await expect( useCase.execute({ @@ -233,7 +240,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, @@ -290,7 +298,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, await makeGithubRegistry(PROJECT_ROOT), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -328,7 +337,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -365,7 +375,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -380,7 +391,173 @@ describe("PluginAddUseCase", () => { const plugins = manifest?.getPlugins("cursor") ?? []; const installed = plugins.find((p) => p.name === "sample-plugin"); expect(installed).toBeDefined(); - expect(installed?.files.size).toBeGreaterThan(0); + expect(installed?.files.size).toBe(0); + const machine = deps.userManifestRepo + .getCurrent() + ?.getPlugins("cursor") + .find((p) => p.name === "sample-plugin"); + expect(machine?.files.size).toBeGreaterThan(0); + expect(machine?.dependents).toEqual([PROJECT_ROOT]); + }); + + const CURSOR_SKILL = "sample-plugin/skills/demo/SKILL.md"; + + function cursorSkillAt(): string { + return join(homedir(), ".cursor/plugins/local", CURSOR_SKILL); + } + + async function addForCursor(deps: Deps, logger: Logger, replace?: boolean): Promise { + await buildAddUseCase(deps, await makeGithubRegistry(PROJECT_ROOT), logger).execute({ + source: GIT_SUBDIR_SOURCE, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + marketplace: "aidd-framework", + interactive: false, + pluginMetadata: PLUGIN_METADATA, + replace, + }); + } + + async function cursorDeps(): Promise { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + deps.pluginFetcher.register(GIT_SUBDIR_SOURCE, PLUGIN_FIXTURE); + deps.fs.setFile(`/built/cursor/plugins/${CURSOR_SKILL}`, "# Demo skill"); + return deps; + } + + it("leaves a plugin dir it finds already there as it was, and tracks none of it", async () => { + const deps = await cursorDeps(); + deps.fs.setFile(cursorSkillAt(), "# Their own skill"); + const logger = new CapturingLogger(); + const machineWritesBefore = deps.userManifestRepo.saveCount; + + await addForCursor(deps, logger); + + expect(deps.fs.getFile(cursorSkillAt())).toBe("# Their own skill"); + const entry = deps.manifestRepo.getCurrent()?.getPlugins("cursor")[0]; + expect(entry?.name).toBe("sample-plugin"); + expect(entry?.files.size).toBe(0); + expect(deps.userManifestRepo.saveCount).toBe(machineWritesBefore); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor") ?? []).toEqual([]); + expect(logger.warnMessages.join("\n")).toContain( + join(homedir(), ".cursor/plugins/local", "sample-plugin") + ); + }); + + it("leaves a found plugin dir alone even when this project installed another plugin", async () => { + const deps = await cursorDeps(); + const manifest = await deps.manifestRepo.load(); + manifest?.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "other-plugin", + "1.0.0", + GIT_SUBDIR_SOURCE, + false, + "user" + ).withFiles(new Map([["other-plugin/skills/demo.md", "recorded-hash"]])) + ); + if (manifest) await deps.manifestRepo.save(manifest); + deps.fs.setFile(cursorSkillAt(), "# Their own skill"); + const machineWritesBefore = deps.userManifestRepo.saveCount; + + await addForCursor(deps, deps.logger); + + expect(deps.fs.getFile(cursorSkillAt())).toBe("# Their own skill"); + expect(pluginNames(deps, "cursor")).toEqual(["other-plugin", "sample-plugin"]); + expect(deps.manifestRepo.getCurrent()?.getPlugins("cursor")[0]?.files.size).toBe(1); + expect(deps.userManifestRepo.saveCount).toBe(machineWritesBefore); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor") ?? []).toEqual([]); + }); + + it("still delivers the plugin's project hooks when its user-scope dir is someone else's", async () => { + const deps = await cursorDeps(); + deps.fs.setFile(cursorSkillAt(), "# Their own skill"); + + await addForCursor(deps, deps.logger); + + expect(deps.fs.getFile(join(PROJECT_ROOT, ".cursor/hooks.json"))).toContain( + "update_memory" + ); + }); + + it("leaves a found plugin dir alone on a local install with no marketplace", async () => { + const deps = await cursorDeps(); + deps.fs.setFile(cursorSkillAt(), "# Their own skill"); + + await buildAddUseCase(deps).execute({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(deps.fs.getFile(cursorSkillAt())).toBe("# Their own skill"); + expect(deps.manifestRepo.getCurrent()?.getPlugins("cursor")[0]?.files.size).toBe(0); + }); + + it("project replace refuses legacy hooks without an install digest and preserves shared files", async () => { + const deps = await cursorDeps(); + await addForCursor(deps, deps.logger); + const hooksBefore = deps.fs.getFile(join(PROJECT_ROOT, ".cursor/hooks.json")); + const savesBefore = deps.manifestRepo.saveCount; + const machineSavesBefore = deps.userManifestRepo.saveCount; + deps.fs.setFile(`/built/cursor/plugins/${CURSOR_SKILL}`, "# Demo skill v2"); + + await expect(addForCursor(deps, deps.logger, true)).rejects.toThrow( + /legacy install digest/ + ); + + expect(deps.fs.getFile(cursorSkillAt())).toBe("# Demo skill"); + expect(deps.fs.getFile(join(PROJECT_ROOT, ".cursor/hooks.json"))).toBe(hooksBefore); + expect(deps.manifestRepo.saveCount).toBe(savesBefore); + expect(deps.userManifestRepo.saveCount).toBe(machineSavesBefore); + expect(pluginNames(deps, "cursor")).toEqual(["sample-plugin"]); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual([ + PROJECT_ROOT, + ]); + }); + + it("attaches project B to A's canonical Cursor file without rewriting A's bytes", async () => { + const deps = await cursorDeps(); + const logger = new CapturingLogger(); + await addForCursor(deps, logger); + const warningsBeforeB = logger.warnMessages.length; + const ownedPath = cursorSkillAt(); + deps.fs.setFile(ownedPath, "A's own bytes"); + const projectB = Manifest.create(); + projectB.addTool("cursor", "1.0.0", []); + const projectBRepo = new InMemoryManifestRepository(projectB, "/B"); + const registryB = await makeGithubRegistry("/B"); + await new PluginAddUseCase( + deps.fs, + projectBRepo, + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs), + deps.hasher, + logger, + registryB, + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo + ).execute({ + source: GIT_SUBDIR_SOURCE, + toolIds: ["cursor"], + projectRoot: "/B", + marketplace: "aidd-framework", + interactive: false, + pluginMetadata: PLUGIN_METADATA, + }); + expect(deps.fs.getFile(ownedPath)).toBe("A's own bytes"); + expect(projectBRepo.getCurrent()?.getPlugins("cursor")[0]?.files.size).toBe(0); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual([ + PROJECT_ROOT, + "/B", + ]); + expect(logger.warnMessages.slice(warningsBeforeB).join("\n")).not.toContain( + "was already there and this project did not install it" + ); }); }); @@ -400,7 +577,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -432,7 +610,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -466,7 +645,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -497,7 +677,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -531,7 +712,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -582,7 +764,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, @@ -634,7 +817,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { kind: "local", path: "/some-plugin" }, diff --git a/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts index 32371c22e..fcecbce57 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts @@ -54,7 +54,8 @@ async function buildUseCase(options: { logger?: Logger | null; prompter?: Prompt deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); const fetchMarketplaceSource = new FetchMarketplaceSourceUseCase(deps.pluginFetcher); const logger = options.logger === null ? undefined : (options.logger ?? deps.logger); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts index 905f99e37..590175a54 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts @@ -12,6 +12,7 @@ import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/appli import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -56,26 +57,79 @@ async function seedManifest(): Promise { // `clean`'s own marketplace cache purge already addresses by hostName, never alias. manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: ALIAS, hostName: HOST_NAME }], + marketplaces: [ + { + alias: ALIAS, + hostName: HOST_NAME, + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], pluginRefs: [REF], }); return manifest; } +function verifiedClaudeRemoval( + fs: InMemoryFileAdapter, + repo: InMemoryManifestRepository, + logger: CapturingLogger, + activator: FakeNativePluginActivator +): PluginRemoveUseCase { + return new PluginRemoveUseCase( + fs, + repo, + logger, + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/host/plugin-registry", + refs: new Map([ + [REF, { enabled: true, scope: "project" }], + [`other-plugin@${HOST_NAME}`, { enabled: true, scope: "project" }], + [`${PLUGIN_NAME}@../../../evil`, { enabled: true, scope: "project" }], + ]), + }), + ], + ]), + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/host/catalogue", + entries: new Map([ + [HOST_NAME, { kind: "registry" as const, source: "/plugin-source" }], + ["../../../evil", { kind: "registry" as const, source: "/plugin-source" }], + ]), + }), + }, + ], + ]) + ); +} + describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { it("purges cache/// once the host confirms it uninstalled, even with content still inside", async () => { const fs = new InMemoryFileAdapter(); const cacheEntry = join(CLAUDE_CACHE_ROOT, HOST_NAME, PLUGIN_NAME, "1.0.0", "plugin.json"); + const foreignCacheEntry = join( + CLAUDE_CACHE_ROOT, + HOST_NAME, + "other-plugin", + "1.0.0", + "plugin.json" + ); await fs.writeFile(cacheEntry, "{}"); + await fs.writeFile(foreignCacheEntry, "foreign bytes"); const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); - const removeUseCase = new PluginRemoveUseCase( - fs, - manifestRepo, - logger, - new Map([["claude", activator]]) - ); + const removeUseCase = verifiedClaudeRemoval(fs, manifestRepo, logger, activator); await removeUseCase.execute({ pluginName: PLUGIN_NAME, @@ -85,6 +139,7 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { expect(activator.uninstalledPlugins).toEqual([REF]); expect(await fs.fileExists(cacheEntry)).toBe(false); + expect(fs.getFile(foreignCacheEntry)).toBe("foreign bytes"); }); it("leaves the plugin's cache in place, and names it, when the host CLI is not on PATH", async () => { @@ -94,12 +149,7 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: false }); const logger = new CapturingLogger(); - const removeUseCase = new PluginRemoveUseCase( - fs, - manifestRepo, - logger, - new Map([["claude", activator]]) - ); + const removeUseCase = verifiedClaudeRemoval(fs, manifestRepo, logger, activator); await removeUseCase.execute({ pluginName: PLUGIN_NAME, @@ -120,12 +170,7 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: true, failOnUninstall: [REF] }); const logger = new CapturingLogger(); - const removeUseCase = new PluginRemoveUseCase( - fs, - manifestRepo, - logger, - new Map([["claude", activator]]) - ); + const removeUseCase = verifiedClaudeRemoval(fs, manifestRepo, logger, activator); await removeUseCase.execute({ pluginName: PLUGIN_NAME, @@ -153,18 +198,19 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { ); manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: ALIAS, hostName: evilHostName }], - pluginRefs: [`${PLUGIN_NAME}@${ALIAS}`], + marketplaces: [ + { + alias: ALIAS, + hostName: evilHostName, + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], + pluginRefs: [`${PLUGIN_NAME}@${evilHostName}`], }); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); - const removeUseCase = new PluginRemoveUseCase( - fs, - manifestRepo, - logger, - new Map([["claude", activator]]) - ); + const removeUseCase = verifiedClaudeRemoval(fs, manifestRepo, logger, activator); await removeUseCase.execute({ pluginName: PLUGIN_NAME, @@ -184,11 +230,11 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { ); const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); const logger = new CapturingLogger(); - const removeUseCase = new PluginRemoveUseCase( + const removeUseCase = verifiedClaudeRemoval( fs, manifestRepo, logger, - new Map([["claude", new FakeNativePluginActivator({ available: true })]]) + new FakeNativePluginActivator({ available: true }) ); await removeUseCase.execute({ @@ -220,7 +266,7 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { expect(logger.allMessages).toStrictEqual([]); }); - it("purges nothing, silently, for a host that declares no plugin cache directory", async () => { + it("refuses Copilot removal without current source proof, preserving its manifest and files", async () => { const fs = new InMemoryFileAdapter(); const manifest = Manifest.create(); manifest.addTool("copilot", "test", []); @@ -234,7 +280,13 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { ); manifest.setNativeRegistrations("copilot", { binary: "copilot", - marketplaces: [{ alias: ALIAS, hostName: HOST_NAME }], + marketplaces: [ + { + alias: ALIAS, + hostName: HOST_NAME, + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], pluginRefs: [REF], }); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); @@ -247,13 +299,16 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { new Map([["copilot", activator]]) ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["copilot"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["copilot"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/unclaimed machine-global host ref/); - expect(activator.uninstalledPlugins).toStrictEqual([REF]); - expect(logger.allMessages).toStrictEqual([]); + expect(activator.uninstalledPlugins).toStrictEqual([]); + expect(manifestRepo.getCurrent()?.getPlugins("copilot")).toHaveLength(1); + expect(manifestRepo.getCurrent()?.getNativeRegistrations("copilot")?.pluginRefs).toEqual([REF]); }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts index 8582973d0..15d4a900f 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts @@ -1,16 +1,25 @@ // `claude`, `codex` and `copilot` only load a plugin once their own CLI has registered it, so // removal must drive `uninstallPlugin` with the same `@` ref install used. import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { ModeAMarketplaceTranslator } from "../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { + HostPluginRegistryReader, + HostPluginRegistryReading, +} from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { UnreadableUserSourceReferencesError } from "../../../../../src/kernel/errors.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; const PROJECT_ROOT = "/test-project"; const MARKETPLACE_NAME = "aidd-framework"; @@ -42,11 +51,35 @@ async function installViaModeA(manifest: Manifest): Promise { manifest, MARKETPLACE_NAME ); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: MARKETPLACE_NAME, + hostName: MARKETPLACE_NAME, + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], + pluginRefs: [REF], + }); +} + +function verifiedSourceReader() { + return { + read: async () => ({ + location: "/host/catalogue", + entries: new Map([ + [MARKETPLACE_NAME, { kind: "registry" as const, source: "/plugin-source" }], + ["upstream", { kind: "registry" as const, source: "/plugin-source" }], + ]), + }), + }; } function buildRemoveUseCase( activator: FakeNativePluginActivator, - logger: CapturingLogger + logger: CapturingLogger, + hostScope: "project" | "user" = "project" ): { removeUseCase: PluginRemoveUseCase; manifestRepo: InMemoryManifestRepository } { const fs = new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(); @@ -54,12 +87,475 @@ function buildRemoveUseCase( fs, manifestRepo, logger, - new Map([["claude", activator]]) + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/host/plugin-registry", + refs: new Map([ + [REF, { enabled: true, scope: hostScope }], + [`${PLUGIN_NAME}@upstream`, { enabled: true, scope: "project" }], + ]), + }), + ], + ]), + undefined, + undefined, + undefined, + new Map([["claude", verifiedSourceReader()]]) ); return { removeUseCase, manifestRepo }; } describe("PluginRemoveUseCase undoes native activation", () => { + it.each(["unrecorded activation", "absent catalogue"])( + "refuses %s without changing project state", + async (missing) => { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + if (missing === "unrecorded activation") { + const registrations = manifest.getNativeRegistrations("claude"); + if (registrations === undefined) throw new Error("Expected native registration"); + manifest.setNativeRegistrations("claude", { ...registrations, pluginRefs: [] }); + } + const before = manifest.toJSON(); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const repo = new InMemoryManifestRepository(manifest); + const remove = new PluginRemoveUseCase( + new InMemoryFileAdapter(), + repo, + new CapturingLogger(), + new Map([["claude", activator]]), + new Map(), + undefined, + undefined, + undefined, + new Map([ + ["claude", { read: async () => ({ location: "/host/catalogue", entries: new Map() }) }], + ]) + ); + await expect( + remove.execute({ pluginName: PLUGIN_NAME, toolIds: ["claude"], projectRoot: PROJECT_ROOT }) + ).rejects.toThrow( + missing === "unrecorded activation" + ? `claude: '${REF}' is not a ref this project enabled; reconcile manually before removal.` + : `claude: catalogue '${MARKETPLACE_NAME}' has unproven host source; reconcile manually.` + ); + expect(manifest.toJSON()).toEqual(before); + expect(repo.saveCount).toBe(0); + expect(activator.uninstalledPlugins).toEqual([]); + } + ); + + it("preserves the native ref and cache when Claude disappears after preflight", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + const repo = new InMemoryManifestRepository(manifest); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const source = verifiedSourceReader(); + let reads = 0; + const cacheFile = join( + homedir(), + ".claude/plugins/cache", + MARKETPLACE_NAME, + PLUGIN_NAME, + "1.0.0/plugin.json" + ); + await fs.writeFile(cacheFile, "keep cache"); + const remove = new PluginRemoveUseCase( + fs, + repo, + logger, + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/host/registry", + refs: new Map([[REF, { enabled: true, scope: "project" }]]), + }), + ], + ]), + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + { + read: async () => { + if (++reads === 2) activator.available = false; + return source.read(); + }, + }, + ], + ]) + ); + + await remove.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifest.getPlugins("claude")).toEqual([]); + expect(manifest.getNativeRegistrations("claude")?.pluginRefs).toEqual([REF]); + expect(await fs.readFile(cacheFile)).toBe("keep cache"); + expect(logger.warnMessages).toContain( + `claude CLI not found on PATH — '${REF}' was not uninstalled from claude's own plugin registry and may still be enabled there.` + ); + }); + + it.each([ + { name: MARKETPLACE_NAME, scope: "user" as const, readsLedger: true }, + { name: MARKETPLACE_NAME, scope: "project" as const, readsLedger: false }, + { name: "unrelated", scope: "user" as const, readsLedger: false }, + ])( + "checks the shared-source ledger only for $name/$scope without blocking project-scoped uninstall", + async ({ name, scope, readsLedger }) => { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + const repo = new InMemoryManifestRepository(manifest); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name, + scope, + source: { kind: "local", path: "/plugin-source" }, + addedAt: "2026-09-16T00:00:00Z", + }) + ); + const ledgerError = new UnreadableUserSourceReferencesError( + "/references.json", + "invalid JSON" + ); + const list = vi.fn(async (): Promise => { + throw ledgerError; + }); + const remove = new PluginRemoveUseCase( + new InMemoryFileAdapter(), + repo, + logger, + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/host/registry", + refs: new Map([[REF, { enabled: true, scope: "project" }]]), + }), + ], + ]), + { + addReference: async () => {}, + removeReference: async () => {}, + listAllReferencingProjects: list, + }, + registry, + undefined, + new Map([["claude", verifiedSourceReader()]]) + ); + + await remove.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(list).toHaveBeenCalledTimes(readsLedger ? 1 : 0); + expect(logger.warnMessages).toEqual(readsLedger ? [ledgerError.message] : []); + expect(activator.uninstalledPlugins).toEqual([REF]); + expect(activator.uninstalledPluginScopes).toEqual(["project"]); + expect(manifest.getNativeRegistrations("claude")?.pluginRefs).toEqual([]); + } + ); + + describe("host registry preflight preserves project state", () => { + const invalidReadings: { + name: string; + reading: HostPluginRegistryReading; + diagnostic: string; + }[] = [ + { + name: "missing registry", + reading: { location: "/host/registry", absent: true }, + diagnostic: "/host/registry", + }, + { + name: "unreadable registry", + reading: { location: "/host/registry", unreadable: "permission denied" }, + diagnostic: "permission denied", + }, + { + name: "empty registry", + reading: { location: "/host/registry", refs: new Map() }, + diagnostic: "/host/registry", + }, + { + name: "disabled activation", + reading: { + location: "/host/registry", + refs: new Map([[REF, { enabled: false, scope: "project" }]]), + }, + diagnostic: "/host/registry", + }, + { + name: "unscoped activation", + reading: { location: "/host/registry", refs: new Map([[REF, { enabled: true }]]) }, + diagnostic: "/host/registry", + }, + ]; + const registryFailures: { name: string; reader?: HostPluginRegistryReader; message: string }[] = + [ + { + name: "no reader", + message: `claude: '${REF}' has no readable host plugin registry; uninstall refused before project changes.`, + }, + ...[new Error("registry unavailable"), "registry unavailable"].map((error, index) => ({ + name: `reader throws ${index === 0 ? "Error" : "string"}`, + reader: { + read: async (): Promise => { + throw error; + }, + }, + message: `claude: '${REF}' host plugin registry read failed (registry unavailable); uninstall refused.`, + })), + ...invalidReadings.map(({ name, reading, diagnostic }) => ({ + name, + reader: new FakeHostPluginRegistryReader(reading), + message: `claude: '${REF}' is not provably enabled with an exact host scope (${diagnostic}); uninstall refused.`, + })), + ]; + + it.each(registryFailures)( + "refuses $name before native or local changes", + async ({ reader, message }) => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + const manifestRepo = new InMemoryManifestRepository(manifest); + const before = manifest.toJSON(); + const cacheFile = join( + homedir(), + ".claude/plugins/cache", + MARKETPLACE_NAME, + PLUGIN_NAME, + "1.0.0/plugin.json" + ); + await fs.writeFile(cacheFile, "cached plugin"); + const activator = new FakeNativePluginActivator({ available: true }); + const remove = new PluginRemoveUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new Map([["claude", activator]]), + reader === undefined ? new Map() : new Map([["claude", reader]]), + undefined, + undefined, + undefined, + new Map([["claude", verifiedSourceReader()]]) + ); + + await expect( + remove.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(message); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifestRepo.saveCount).toBe(0); + expect(manifest.toJSON()).toEqual(before); + expect(await fs.readFile(cacheFile)).toBe("cached plugin"); + } + ); + }); + + it("removes only the local projection when Claude CLI is unavailable and no native catalogue source was recorded", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + MARKETPLACE_NAME + ); + const repo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: false }); + const logger = new CapturingLogger(); + const removeUseCase = new PluginRemoveUseCase( + fs, + repo, + logger, + new Map([["claude", activator]]) + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(repo.getCurrent()?.getPlugins("claude")).toEqual([]); + expect(logger.warnMessages.join(" ")).toMatch(/CLI not found|host ref may remain/); + }); + + it("does not uninstall or purge a same-name foreign host ref even without a machine-global claim", async () => { + const fs = new InMemoryFileAdapter(); + const cacheEntry = join( + homedir(), + ".claude/plugins/cache", + MARKETPLACE_NAME, + PLUGIN_NAME, + "1.0.0/plugin.json" + ); + fs.setFile(cacheEntry, "cache witness"); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: MARKETPLACE_NAME, + hostName: MARKETPLACE_NAME, + provenance: { kind: "registry", source: "/aidd/old-source" }, + }, + ], + pluginRefs: [REF], + }); + const repo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const removeUseCase = new PluginRemoveUseCase( + fs, + repo, + new CapturingLogger(), + new Map([["claude", activator]]), + new Map(), + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/host/catalogue", + entries: new Map([ + [MARKETPLACE_NAME, { kind: "registry" as const, source: "/foreign/source" }], + ]), + }), + }, + ], + ]) + ); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/source differs/); + expect(activator.uninstalledPlugins).toEqual([]); + expect(fs.getFile(cacheEntry)).toBe("cache witness"); + expect(repo.getCurrent()?.getPlugins("claude")).toHaveLength(1); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginRefs).toEqual([REF]); + }); + + it("refuses an old AIDD catalogue claim when the current host source is foreign under the same name and ref", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(); + const machine = Manifest.create(); + machine.addTool("claude", "test", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [PROJECT_ROOT] }], + }); + const userRepo = new InMemoryManifestRepository(machine); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["claude", activator]]), + new Map(), + undefined, + undefined, + userRepo, + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/host/catalogue", + entries: new Map([ + [MARKETPLACE_NAME, { kind: "registry" as const, source: "/foreign/source" }], + ]), + }), + }, + ], + ]) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: MARKETPLACE_NAME, + hostName: MARKETPLACE_NAME, + provenance: { kind: "registry", source: "/aidd/old-source" }, + }, + ], + pluginRefs: [REF], + }); + await manifestRepo.save(manifest); + const savesBefore = manifestRepo.saveCount; + const machineSavesBefore = userRepo.saveCount; + + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/source differs|reconcile manually/); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifestRepo.saveCount).toBe(savesBefore); + expect(userRepo.saveCount).toBe(machineSavesBefore); + expect( + manifestRepo + .getCurrent() + ?.getPlugins("claude") + .map((plugin) => plugin.name) + ).toEqual([PLUGIN_NAME]); + expect(manifestRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginRefs).toEqual([REF]); + expect(userRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual([ + { ref: REF, dependents: [PROJECT_ROOT] }, + ]); + }); + it("uninstalls via the host CLI using the same @ ref install used", async () => { const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); @@ -95,11 +591,12 @@ describe("PluginRemoveUseCase undoes native activation", () => { }); expect(activator.uninstalledPlugins).toEqual([]); - expect(logger.warnMessages).toHaveLength(1); - expect(logger.warnMessages[0]).toContain("claude"); - expect(logger.warnMessages[0]).toContain(REF); + expect( + logger.warnMessages.some((message) => message.includes("claude") && message.includes(REF)) + ).toBe(true); const loaded = await manifestRepo.load(); expect(loaded?.getPlugins("claude").some((p) => p.name === PLUGIN_NAME)).toBe(false); + expect(loaded?.getNativeRegistrations("claude")?.pluginRefs).toEqual([REF]); }); it("warns naming the host and message when the host CLI reports the plugin already absent", async () => { @@ -122,9 +619,9 @@ describe("PluginRemoveUseCase undoes native activation", () => { }) ).resolves.not.toThrow(); - expect(logger.warnMessages).toHaveLength(1); - expect(logger.warnMessages[0]).toContain("claude"); - expect(logger.warnMessages[0]).toContain(REF); + expect( + logger.warnMessages.some((message) => message.includes("claude") && message.includes(REF)) + ).toBe(true); }); it("never calls the host CLI for a plugin installed without a recorded marketplace", async () => { @@ -155,13 +652,13 @@ describe("PluginRemoveUseCase undoes native activation", () => { // A real `claude` binary registers at its own implicit `"user"` default whatever scope the // manifest records for the plugin's files, and refuses an uninstall aimed at another scope. - it("falls back to the other scope when the manifest's own scope does not match what was actually registered", async () => { + it("uses the host registry's exact user scope when the project manifest differs", async () => { const activator = new FakeNativePluginActivator({ available: true, installedAtScope: new Map([[REF, "user"]]), }); const logger = new CapturingLogger(); - const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger, "user"); const manifest = Manifest.create(); manifest.addTool("claude", "test", []); await installViaModeA(manifest); @@ -174,7 +671,7 @@ describe("PluginRemoveUseCase undoes native activation", () => { }); expect(activator.uninstalledPlugins).toEqual([REF]); - expect(activator.uninstalledPluginScopes).toEqual(["project", "user"]); + expect(activator.uninstalledPluginScopes).toEqual(["user"]); expect(logger.warnMessages).toEqual([]); }); @@ -196,8 +693,14 @@ describe("PluginRemoveUseCase undoes native activation", () => { ); manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: "local", hostName: "upstream" }], - pluginRefs: [], + marketplaces: [ + { + alias: "local", + hostName: "upstream", + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], + pluginRefs: [`${PLUGIN_NAME}@upstream`], }); await manifestRepo.save(manifest); @@ -232,7 +735,11 @@ describe("PluginRemoveUseCase undoes native activation", () => { refs: new Map([[hostRef, { enabled: true, scope: "user" }]]), }), ], - ]) + ]), + undefined, + undefined, + undefined, + new Map([["claude", verifiedSourceReader()]]) ); const manifest = Manifest.create(); manifest.addTool("claude", "test", []); @@ -246,8 +753,14 @@ describe("PluginRemoveUseCase undoes native activation", () => { ); manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: "local", hostName: "upstream" }], - pluginRefs: [], + marketplaces: [ + { + alias: "local", + hostName: "upstream", + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], + pluginRefs: [`${PLUGIN_NAME}@upstream`], }); await manifestRepo.save(manifest); @@ -261,7 +774,7 @@ describe("PluginRemoveUseCase undoes native activation", () => { expect(activator.uninstalledPluginScopes).toEqual(["user"]); }); - it("warns naming the alias when this tool's own native registrations exist but name no entry for it", async () => { + it("refuses a legacy alias with no recorded native source before changing the project", async () => { const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); @@ -275,19 +788,19 @@ describe("PluginRemoveUseCase undoes native activation", () => { }); await manifestRepo.save(manifest); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/no recorded native catalogue source/); - expect(activator.uninstalledPlugins).toEqual([REF]); - expect(logger.warnMessages.some((m) => m.includes(MARKETPLACE_NAME))).toBe(true); + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifestRepo.getCurrent()?.getPlugins("claude")).toHaveLength(1); }); - // Two reads, not three: `removeNativeActivation` resolves the host name and the warn gate - // from one read, and `purgeCachedPlugin`'s own read is a separate decision made after it. - it("reads this tool's own native registrations once per decision, not twice for the warn gate", async () => { + it("reads an unproved native registration only for preflight, not for uninstall or cache", async () => { const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); @@ -304,13 +817,15 @@ describe("PluginRemoveUseCase undoes native activation", () => { if (stored === null) throw new Error("unreachable — just saved"); const spy = vi.spyOn(stored, "getNativeRegistrations"); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/no recorded native catalogue source/); - expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledTimes(1); }); it("uninstalls at the scope the host's own registry names directly, one attempt", async () => { @@ -334,7 +849,11 @@ describe("PluginRemoveUseCase undoes native activation", () => { refs: new Map([[REF, { enabled: true, scope: "user" }]]), }), ], - ]) + ]), + undefined, + undefined, + undefined, + new Map([["claude", verifiedSourceReader()]]) ); const manifest = Manifest.create(); manifest.addTool("claude", "test", []); @@ -360,8 +879,14 @@ describe("PluginRemoveUseCase undoes native activation", () => { await installViaModeA(manifest); manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: MARKETPLACE_NAME, hostName: "upstream" }], - pluginRefs: [], + marketplaces: [ + { + alias: MARKETPLACE_NAME, + hostName: "upstream", + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], + pluginRefs: [`${PLUGIN_NAME}@upstream`], }); await manifestRepo.save(manifest); @@ -393,9 +918,9 @@ describe("PluginRemoveUseCase undoes native activation", () => { projectRoot: PROJECT_ROOT, }); - expect(logger.warnMessages).toStrictEqual([ - `claude plugin uninstall '${REF}' failed: plugin \`${REF}\` is not installed — an entry for it may remain in claude's own plugin registry.`, - ]); + expect(logger.warnMessages).toContain( + `claude plugin uninstall '${REF}' failed: plugin \`${REF}\` is not installed — an entry for it may remain in claude's own plugin registry.` + ); }); it("propagates a failure that is not the host CLI refusing", async () => { @@ -416,3 +941,48 @@ describe("PluginRemoveUseCase undoes native activation", () => { ).rejects.toThrow("activator crashed uninstalling a plugin"); }); }); + +describe("PluginRemoveUseCase undoes only the activation this project made", () => { + async function removeWithRecordedRefs(pluginRefs: readonly string[]) { + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: MARKETPLACE_NAME, + hostName: MARKETPLACE_NAME, + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], + pluginRefs, + }); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + return { activator, logger, manifestRepo }; + } + + it("leaves enabled, and names, a ref the host already had before this project", async () => { + const { activator, logger, manifestRepo } = await removeWithRecordedRefs([]); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(logger.warnMessages.join("\n")).toContain(REF); + const loaded = await manifestRepo.load(); + expect(loaded?.getPlugins("claude").some((p) => p.name === PLUGIN_NAME)).toBe(false); + }); + + it("uninstalls a ref this project's own activation enabled", async () => { + const { activator } = await removeWithRecordedRefs([REF]); + + expect(activator.uninstalledPlugins).toEqual([REF]); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts index 67d584575..cbdf8695c 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts @@ -23,7 +23,33 @@ const USER_CONFIG_DIR = "/fake-home/.config/aidd"; const PLUGIN_NAME = "aidd-vcs"; const REF = `${PLUGIN_NAME}@${FRAMEWORK_MARKETPLACE_NAME}`; -function seedManifest(marketplaceAlias: string = FRAMEWORK_MARKETPLACE_NAME): Manifest { +function proof() { + return { + kind: "effective-list" as const, + root: "/exact/codex/root", + sourceType: "local", + source: "/plugin-source", + }; +} + +function sourceReader() { + return { + read: async () => ({ + location: "/codex/plugin/marketplace/list", + entries: new Map( + [FRAMEWORK_MARKETPLACE_NAME, "upstream", "other-mkt", "gone-mkt"].map((name) => [ + name, + proof(), + ]) + ), + }), + }; +} + +function seedManifest( + marketplaceAlias: string = FRAMEWORK_MARKETPLACE_NAME, + recordNativeRegistration = true +): Manifest { const manifest = Manifest.create(); manifest.addTool("codex", "1.0.0", []); manifest.addPlugin( @@ -38,6 +64,12 @@ function seedManifest(marketplaceAlias: string = FRAMEWORK_MARKETPLACE_NAME): Ma marketplace: marketplaceAlias, }) ); + if (recordNativeRegistration) + manifest.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: marketplaceAlias, hostName: marketplaceAlias, provenance: proof() }], + pluginRefs: [`${PLUGIN_NAME}@${marketplaceAlias}`], + }); return manifest; } @@ -73,7 +105,8 @@ function buildUseCase( activator: FakeNativePluginActivator, logger: CapturingLogger, manifest: Manifest = seedManifest(), - marketplaceRegistry: InMemoryMarketplaceRegistry = seedSharedMarketplaceRegistry() + marketplaceRegistry: InMemoryMarketplaceRegistry = seedSharedMarketplaceRegistry(), + userManifestRepo?: InMemoryManifestRepository ): { removeUseCase: PluginRemoveUseCase; manifestRepo: InMemoryManifestRepository; @@ -87,38 +120,92 @@ function buildUseCase( new Map([["codex", activator]]), new Map(), userSourceReferences, - marketplaceRegistry + marketplaceRegistry, + userManifestRepo, + new Map([["codex", sourceReader()]]) ); return { removeUseCase, manifestRepo }; } describe("plugin remove guards a ref another project on this machine still needs", () => { - it("leaves codex's ref enabled and names the other project still referencing the shared source", async () => { + it("refuses a machine-global ref without a canonical machine claim even when source and references look AIDD-owned", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); - seedReferences(fs, [OTHER_PROJECT]); + seedReferences(fs, []); + fs.setFile(`${PROJECT_ROOT}/user-note.md`, "user bytes"); const activator = new FakeNativePluginActivator({ available: true }); - const logger = new CapturingLogger(); - const { removeUseCase } = buildUseCase(fs, activator, logger); + const { removeUseCase, manifestRepo } = buildUseCase(fs, activator, new CapturingLogger()); + const savesBefore = manifestRepo.saveCount; - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/canonical machine claim|unclaimed machine-global/); - expect(activator.uninstalledPlugins).not.toContain(REF); - expect( - logger.warnMessages.some((m) => m.includes("left enabled") && m.includes(OTHER_PROJECT)) - ).toBe(true); + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifestRepo.saveCount).toBe(savesBefore); + expect(manifestRepo.getCurrent()?.getPlugins("codex")).toHaveLength(1); + expect(manifestRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([REF]); + expect(fs.getFile(`${PROJECT_ROOT}/user-note.md`)).toBe("user bytes"); }); - it("still uninstalls codex's ref when no other project references the shared source", async () => { - // `plugin remove` never drops its own claim, so a project's own root must be subtracted - // from `listAllReferencingProjects` or it reads itself back as another project. + it("detaches only A's canonical claim while B's machine-global host ref remains enabled", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); - seedReferences(fs, []); + seedReferences(fs, [OTHER_PROJECT]); + const siblingRef = `aidd-dev@${FRAMEWORK_MARKETPLACE_NAME}`; + const otherCatalogueRef = `${PLUGIN_NAME}@other-mkt`; + const untouchedRefs = [siblingRef, otherCatalogueRef]; + const project = seedManifest(); + project.addPlugin( + "codex", + InstalledPlugin.fromJSON({ + name: "aidd-dev", + source: { kind: "local", path: "/plugin-source" }, + version: "1.0.0", + strict: true, + files: {}, + scope: "project", + marketplace: FRAMEWORK_MARKETPLACE_NAME, + }) + ); + const marketplaces = [ + { alias: "other-mkt", hostName: "other-mkt", provenance: proof() }, + { + alias: FRAMEWORK_MARKETPLACE_NAME, + hostName: FRAMEWORK_MARKETPLACE_NAME, + provenance: proof(), + }, + ]; + project.setNativeRegistrations("codex", { + binary: "codex", + marketplaces, + pluginRefs: [REF, ...untouchedRefs], + }); + fs.setFile(`${OTHER_PROJECT}/plugin-content.md`, "B's plugin bytes"); + fs.setFile(`${PROJECT_ROOT}/user-note.md`, "user bytes"); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces, + pluginRefs: [REF, ...untouchedRefs], + pluginClaims: [REF, ...untouchedRefs].map((ref) => ({ + ref, + dependents: [PROJECT_ROOT, OTHER_PROJECT], + })), + }); + const userRepo = new InMemoryManifestRepository(machine); const activator = new FakeNativePluginActivator({ available: true }); - const { removeUseCase } = buildUseCase(fs, activator, new CapturingLogger()); + const { removeUseCase, manifestRepo } = buildUseCase( + fs, + activator, + new CapturingLogger(), + project, + seedSharedMarketplaceRegistry(), + userRepo + ); await removeUseCase.execute({ pluginName: PLUGIN_NAME, @@ -126,59 +213,94 @@ describe("plugin remove guards a ref another project on this machine still needs projectRoot: PROJECT_ROOT, }); - expect(activator.uninstalledPlugins).toContain(REF); + expect(activator.uninstalledPlugins).toEqual([]); + expect( + manifestRepo + .getCurrent() + ?.getPlugins("codex") + .map((plugin) => plugin.name) + ).toEqual(["aidd-dev"]); + expect(manifestRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual( + untouchedRefs + ); + expect(manifestRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toEqual( + marketplaces + ); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: [OTHER_PROJECT] }, + ...untouchedRefs.map((ref) => ({ ref, dependents: [PROJECT_ROOT, OTHER_PROJECT] })), + ]); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + REF, + ...untouchedRefs, + ]); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toEqual( + marketplaces + ); + expect(fs.getFile(`${OTHER_PROJECT}/plugin-content.md`)).toBe("B's plugin bytes"); + expect(fs.getFile(`${PROJECT_ROOT}/user-note.md`)).toBe("user bytes"); }); - // `refAnotherProjectStillNeeds` matches the ref's suffix against `sharedSourceHostName`, so - // a ref moved to `hostName` while that stays the alias silently stops guarding anything. - it("guards by the host's own ref when this project's alias diverges from the catalog's declared name", async () => { - // The alias is always the reserved `FRAMEWORK_MARKETPLACE_NAME`, which gates the guard; - // the divergence under test is between it and what the catalog declares as `hostName`. + it("refuses an unclaimed machine-global ref by hostName even when its alias is shared", async () => { const HOST_NAME = "upstream"; const HOST_REF = `${PLUGIN_NAME}@${HOST_NAME}`; const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); - const logger = new CapturingLogger(); const manifest = seedManifest(); manifest.setNativeRegistrations("codex", { binary: "codex", - marketplaces: [{ alias: FRAMEWORK_MARKETPLACE_NAME, hostName: HOST_NAME }], + marketplaces: [ + { alias: FRAMEWORK_MARKETPLACE_NAME, hostName: HOST_NAME, provenance: proof() }, + ], pluginRefs: [], }); - const { removeUseCase } = buildUseCase(fs, activator, logger, manifest); + const { removeUseCase, manifestRepo } = buildUseCase( + fs, + activator, + new CapturingLogger(), + manifest + ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(new RegExp(`${HOST_REF}.*unclaimed machine-global`)); - expect(activator.uninstalledPlugins).not.toContain(HOST_REF); - expect( - logger.warnMessages.some((m) => m.includes("left enabled") && m.includes(HOST_REF)) - ).toBe(true); + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifestRepo.getCurrent()?.getPlugins("codex")).toHaveLength(1); }); - it("uninstalls by the alias ref and logs no warning when this tool has no native registrations at all", async () => { + it("refuses legacy removal without native registrations before any host or local mutation", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, []); const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); - const { removeUseCase } = buildUseCase(fs, activator, logger); + const { removeUseCase, manifestRepo } = buildUseCase( + fs, + activator, + logger, + seedManifest(FRAMEWORK_MARKETPLACE_NAME, false) + ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/no recorded native catalogue source/); - expect(activator.uninstalledPlugins).toContain(REF); + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifestRepo.getCurrent()?.getPlugins("codex")).toHaveLength(1); expect(logger.warnMessages).toEqual([]); }); - describe("a ref outside the shared source", () => { - it("uninstalls a ref from a marketplace that is not the shared source, whatever other projects reference", async () => { + describe("a machine-global ref outside the shared source", () => { + it("refuses an unclaimed ref even when another marketplace is unrelated to the framework source", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); @@ -193,7 +315,7 @@ describe("plugin remove guards a ref another project on this machine still needs addedAt: "2026-01-01T00:00:00.000Z", }) ); - const { removeUseCase } = buildUseCase( + const { removeUseCase, manifestRepo } = buildUseCase( fs, activator, logger, @@ -201,36 +323,45 @@ describe("plugin remove guards a ref another project on this machine still needs registry ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/unclaimed machine-global/); - expect(activator.uninstalledPlugins).toStrictEqual([`${PLUGIN_NAME}@other-mkt`]); - expect(logger.warnMessages).toStrictEqual([]); + expect(activator.uninstalledPlugins).toStrictEqual([]); + expect(manifestRepo.getCurrent()?.getPlugins("codex")).toHaveLength(1); }); - it("uninstalls a ref whose marketplace this project's registry no longer lists", async () => { + it("refuses an unclaimed ref whose marketplace the project's local registry no longer lists", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); - const { removeUseCase } = buildUseCase(fs, activator, logger, seedManifest("gone-mkt")); + const { removeUseCase, manifestRepo } = buildUseCase( + fs, + activator, + logger, + seedManifest("gone-mkt") + ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/unclaimed machine-global/); - expect(activator.uninstalledPlugins).toStrictEqual([`${PLUGIN_NAME}@gone-mkt`]); - expect(logger.warnMessages).toStrictEqual([]); + expect(activator.uninstalledPlugins).toStrictEqual([]); + expect(manifestRepo.getCurrent()?.getPlugins("codex")).toHaveLength(1); }); }); - describe("without one of the guard's two registries", () => { - it("skips the guard when no references registry is wired, even for the shared source", async () => { + describe("without one of the optional shared-source registries", () => { + it("still refuses unclaimed machine-global removal without references.json", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); @@ -241,19 +372,23 @@ describe("plugin remove guards a ref another project on this machine still needs new Map([["codex", activator]]), new Map(), undefined, - seedSharedMarketplaceRegistry() + seedSharedMarketplaceRegistry(), + undefined, + new Map([["codex", sourceReader()]]) ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/unclaimed machine-global/); - expect(activator.uninstalledPlugins).toStrictEqual([REF]); + expect(activator.uninstalledPlugins).toStrictEqual([]); }); - it("skips the guard when no marketplace registry is wired", async () => { + it("still refuses unclaimed machine-global removal without a local marketplace registry", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); @@ -263,16 +398,21 @@ describe("plugin remove guards a ref another project on this machine still needs new CapturingLogger(), new Map([["codex", activator]]), new Map(), - new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR) + new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR), + undefined, + undefined, + new Map([["codex", sourceReader()]]) ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/unclaimed machine-global/); - expect(activator.uninstalledPlugins).toStrictEqual([REF]); + expect(activator.uninstalledPlugins).toStrictEqual([]); }); }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts index 3e3dc52f1..6dfe4ba3a 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts @@ -70,7 +70,8 @@ async function installPlugin( deps.hasher, deps.logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await addUseCase.execute({ source: { kind: "local", path: fixture }, diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts index b43bbb6d8..6a000ab65 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts @@ -1,11 +1,19 @@ import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; +import { UserPluginDistributionLoader } from "../../../../../src/contexts/framework/application/ownership/user-plugin-distribution-loader.js"; +import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { UserPluginUpdateUseCase } from "../../../../../src/contexts/framework/application/ownership/user-plugin-update-use-case.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; -import { PluginUpdateUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; +import type { EnsureBuiltMarketplace } from "../../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; @@ -40,18 +48,29 @@ async function makeRegistry(): Promise { return registry; } -function makeUpdateUseCase(deps: Deps, registry: InMemoryMarketplaceRegistry): PluginUpdateUseCase { - return new PluginUpdateUseCase( - deps.fs, - deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), - deps.hasher, - { - ensureBuilt: fakeEnsureBuiltMarketplace(), - marketplaceRegistry: registry, - homedir: () => HOME, - } +function makeUpdateUseCase( + deps: Deps, + registry: InMemoryMarketplaceRegistry, + nativeHost: NativeHostRegistrationGate = new NativeHostRegistrationGate(new Map(), new Map()), + ensureBuilt: EnsureBuiltMarketplace = fakeEnsureBuiltMarketplace() +): UserPluginUpdateUseCase { + return new UserPluginUpdateUseCase( + deps.userManifestRepo, + new UserPluginFileUpdater( + deps.fs, + new UserPluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), + deps.hasher, + { + ensureBuilt, + marketplaceRegistry: registry, + homedir: () => HOME, + } + ), + deps.logger, + nativeHost ); } @@ -72,7 +91,8 @@ async function installStalePlugin( deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: GIT_SUBDIR_SOURCE, toolIds: ["cursor"], @@ -88,9 +108,362 @@ async function installStalePlugin( if (plugin === undefined) throw new Error("plugin not found"); manifest.updatePlugin("cursor", plugin.withVersion("0.0.1")); await deps.manifestRepo.save(manifest); + const machine = deps.userManifestRepo.getCurrent(); + if (machine === null) throw new Error("user manifest not found"); + const owned = machine.getPlugins("cursor").find((p) => p.name === "sample-plugin"); + if (owned === undefined) throw new Error("machine plugin not found"); + // The fake built-tree install records this file but its test adapter does not deliver it. + // Supply the recorded install bytes so the provenance guard has a real current file. + deps.fs.setFile(join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"), "# Demo skill"); + machine.updatePlugin("cursor", owned.withVersion("0.0.1")); + await deps.userManifestRepo.save(machine); +} + +function attachFutureSupportedNativeClaim(deps: Deps) { + const machine = deps.userManifestRepo.getCurrent(); + if (machine === null) throw new Error("fixture missing machine manifest"); + machine.addTool("copilot", "future-supported", []); + machine.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [ + { + alias: "aidd-catalog", + hostName: "aidd-catalog", + provenance: { kind: "registry", source: "/aidd/catalog" }, + }, + ], + pluginRefs: ["native-plugin@aidd-catalog"], + pluginClaims: [{ ref: "native-plugin@aidd-catalog", dependents: [PROJECT_ROOT] }], + }); + const activator = new FakeNativePluginActivator({ available: true }); + const nativeHost = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "/host/plugin-registry", + refs: new Map([ + ["native-plugin@aidd-catalog", { enabled: true, scope: "user" as const }], + ]), + }), + }, + ], + ]), + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "/verified/future/host-source-port", + entries: new Map([ + ["aidd-catalog", { kind: "registry" as const, source: "/aidd/catalog" }], + ]), + }), + }, + ], + ]) + ); + return { machine, activator, nativeHost }; } describe("PluginUpdateUseCase — built-tree materialization", () => { + it("refuses an absent machine manifest before touching user files", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + const registry = await makeRegistry(); + await deps.userManifestRepo.delete(); + const path = join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"); + deps.fs.setFile(path, "user bytes"); + const savesBefore = deps.userManifestRepo.saveCount; + + await expect( + makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/No machine manifest.*ownership/); + + expect(deps.userManifestRepo.getCurrent()).toBeNull(); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(deps.fs.getFile(path)).toBe("user bytes"); + }); + + it("checks current user bytes inside the machine repository's exclusive-access boundary", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const path = join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"); + const before = deps.userManifestRepo.getCurrent()?.toJSON(); + const savesBefore = deps.userManifestRepo.saveCount; + let acquired = false; + Object.assign(deps.userManifestRepo, { + withExclusiveAccess: async (operation: () => Promise) => { + acquired = true; + deps.fs.setFile(path, "user edit at lock acquisition"); + return operation(); + }, + }); + + await expect( + makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/edited after install/); + + expect(acquired).toBe(true); + expect(deps.fs.getFile(path)).toBe("user edit at lock acquisition"); + expect(deps.userManifestRepo.getCurrent()?.toJSON()).toStrictEqual(before); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + }); + + it.each([{ dependents: [] }, { dependents: [PROJECT_ROOT, "/B"] }])( + "warns only for actual dependent projects when updating shared native and file plugins: $dependents", + async ({ dependents }) => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const { machine, activator, nativeHost } = attachFutureSupportedNativeClaim(deps); + const owned = machine.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin"); + const native = machine.getNativeRegistrations("copilot"); + if (owned === undefined || native === undefined) + throw new Error("fixture missing selected claims"); + machine.updatePlugin("cursor", owned.withDependents(dependents)); + machine.setNativeRegistrations("copilot", { + ...native, + pluginClaims: [{ ref: "native-plugin@aidd-catalog", dependents }], + }); + const warnings = vi.spyOn(deps.logger, "warn").mockImplementation(() => {}); + + expect( + await makeUpdateUseCase(deps, registry, nativeHost).execute({ + toolIds: ["copilot", "cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toStrictEqual(["native-plugin@aidd-catalog", "sample-plugin"]); + + expect(warnings.mock.calls).toStrictEqual( + dependents.length === 0 + ? [] + : [ + [ + `copilot: updating native ref 'native-plugin@aidd-catalog' affects ${PROJECT_ROOT}, /B; projects must refresh their local integrations afterward.`, + ], + [ + `cursor: updating user-scope 'sample-plugin' affects ${PROJECT_ROOT}, /B; each project must refresh its own integration afterward.`, + ], + ] + ); + expect(activator.updatedPlugins).toStrictEqual(["native-plugin@aidd-catalog"]); + const after = deps.userManifestRepo.getCurrent(); + expect(after?.getNativeRegistrations("copilot")?.pluginClaims).toStrictEqual([ + { ref: "native-plugin@aidd-catalog", dependents }, + ]); + expect( + after?.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin")?.dependents + ).toStrictEqual(dependents); + expect( + after?.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin")?.version + ).toBe("1.0.0"); + } + ); + + it("updates only the selected user plugin and exact native ref, preserving unrelated claims and project records", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const { machine, activator, nativeHost } = attachFutureSupportedNativeClaim(deps); + const native = machine.getNativeRegistrations("copilot"); + if (native === undefined) throw new Error("fixture missing native registrations"); + const otherRef = "native-plugin@other-catalog"; + machine.setNativeRegistrations("copilot", { + ...native, + pluginClaims: [...(native.pluginClaims ?? []), { ref: otherRef, dependents: ["/B"] }], + }); + const unselected = InstalledPlugin.fromMetadata( + "unselected-plugin", + "0.0.1", + { kind: "local", path: "/unselected/source" }, + false, + "user" + ).withDependents([PROJECT_ROOT, "/B"]); + const projectOnly = InstalledPlugin.fromMetadata( + "project-only", + "0.0.1", + { kind: "local", path: "/project/source" }, + false, + "project" + ); + machine.addPlugin("cursor", unselected); + machine.addPlugin("cursor", projectOnly); + const owned = machine.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin"); + if (owned === undefined) throw new Error("fixture missing user plugin"); + machine.updatePlugin("cursor", owned.withDependents([PROJECT_ROOT, "/B"])); + const claimsBefore = machine.getNativeRegistrations("copilot")?.pluginClaims; + deps.fs.setFile(BUILT_SKILL, "# Selected update"); + const otherPath = join(USER_PLUGINS_DIR, "unselected-plugin/skills/demo/SKILL.md"); + deps.fs.setFile(otherPath, "unselected user bytes"); + + expect( + await makeUpdateUseCase(deps, registry, nativeHost).execute({ + pluginNames: ["native-plugin@aidd-catalog", "sample-plugin"], + toolIds: ["copilot", "cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toEqual(["native-plugin@aidd-catalog", "sample-plugin"]); + + expect(activator.updatedPlugins).toEqual(["native-plugin@aidd-catalog"]); + const after = deps.userManifestRepo.getCurrent(); + expect(after?.getNativeRegistrations("copilot")?.pluginClaims).toEqual(claimsBefore); + expect( + after?.getPlugins("cursor").find((plugin) => plugin.name === "unselected-plugin") + ).toEqual(unselected); + expect(after?.getPlugins("cursor").find((plugin) => plugin.name === "project-only")).toEqual( + projectOnly + ); + expect( + after?.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin")?.dependents + ).toEqual([PROJECT_ROOT, "/B"]); + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"))).toBe( + "# Selected update" + ); + expect(deps.fs.getFile(otherPath)).toBe("unselected user bytes"); + }); + + it("refuses one ambiguous native name among several selected plugins before any file or host update", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const { machine, activator, nativeHost } = attachFutureSupportedNativeClaim(deps); + const native = machine.getNativeRegistrations("copilot"); + if (native === undefined) throw new Error("fixture missing native registrations"); + machine.setNativeRegistrations("copilot", { + ...native, + pluginClaims: [ + ...(native.pluginClaims ?? []), + { + ref: "native-plugin@other-catalog", + dependents: ["/B"], + }, + ], + }); + const before = machine.toJSON(); + const path = join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"); + const bytes = deps.fs.getFile(path); + const savesBefore = deps.userManifestRepo.saveCount; + deps.fs.setFile(BUILT_SKILL, "# Must not be delivered"); + + await expect( + makeUpdateUseCase(deps, registry, nativeHost).execute({ + pluginNames: ["sample-plugin", "native-plugin"], + toolIds: ["cursor", "copilot"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/Multiple native catalogues.*exact/); + + expect(activator.updatedPlugins).toEqual([]); + expect(deps.fs.getFile(path)).toBe(bytes); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(deps.userManifestRepo.getCurrent()?.toJSON()).toEqual(before); + }); + + it("excludes built Cursor hooks from the updated user plugin and leaves both projects' hooks unchanged", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + deps.fs.setFile(BUILT_SKILL, "# Updated skill"); + const builtHooks = JSON.stringify({ hooks: { preToolUse: [{ command: "node pre.js" }] } }); + deps.fs.setFile("/built/cursor/plugins/sample-plugin/hooks/hooks.json", builtHooks); + deps.fs.setFile("/built/cursor/plugins/sample-plugin/hooks/pre.js", "built hook script"); + const hookConfig = (timeout: number) => + JSON.stringify({ + version: 1, + hooks: { preToolUse: [{ command: "node ./.cursor/hooks/sample-plugin/pre.js", timeout }] }, + }); + const projectFiles = new Map([ + [join(PROJECT_ROOT, ".cursor/hooks.json"), hookConfig(10)], + [join(PROJECT_ROOT, ".cursor/hooks/sample-plugin/pre.js"), "A's adapted script"], + ["/B/.cursor/hooks.json", hookConfig(20)], + ["/B/.cursor/hooks/sample-plugin/pre.js", "B's adapted script"], + ]); + for (const [path, content] of projectFiles) deps.fs.setFile(path, content); + + expect( + await makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toEqual(["sample-plugin"]); + + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"))).toBe( + "# Updated skill" + ); + expect( + deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/hooks/hooks.json")) + ).toBeUndefined(); + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/hooks/pre.js"))).toBeUndefined(); + expect( + [...(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.files.keys() ?? [])].some( + (path) => path.startsWith("sample-plugin/hooks/") + ) + ).toBe(false); + for (const [path, content] of projectFiles) expect(deps.fs.getFile(path)).toBe(content); + }); + + it("uses the plugin's named marketplace build rather than the first registered catalogue", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "unrelated-first", + source: { kind: "local", path: "/unrelated" }, + scope: "project", + addedAt: "2026-05-01T00:00:00.000Z", + }) + ); + for (const marketplace of await (await makeRegistry()).list(PROJECT_ROOT)) + await registry.save(PROJECT_ROOT, marketplace); + await installStalePlugin(deps, registry); + deps.fs.setFile("/built/right/plugins/sample-plugin/skills/demo/SKILL.md", "# Right catalogue"); + deps.fs.setFile("/built/wrong/plugins/sample-plugin/skills/demo/SKILL.md", "# Wrong catalogue"); + const ensureBuilt: EnsureBuiltMarketplace = { + execute: async ({ marketplace }) => ({ + builtDir: marketplace.name === "aidd-framework" ? "/built/right" : "/built/wrong", + version: "test", + rebuilt: true, + }), + }; + + expect( + await makeUpdateUseCase(deps, registry, undefined, ensureBuilt).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toEqual(["sample-plugin"]); + + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"))).toBe( + "# Right catalogue" + ); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.marketplace).toBe( + "aidd-framework" + ); + }); + it("re-materializes from the built tree for a marketplace plugin", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "cursor"); @@ -102,13 +475,17 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { const updated = await makeUpdateUseCase(deps, registry).execute({ toolIds: ["cursor"], projectRoot: PROJECT_ROOT, + scope: "user", }); expect(updated).toContain("sample-plugin"); - const manifest = await deps.manifestRepo.load(); + const manifest = deps.userManifestRepo.getCurrent(); const plugin = manifest?.getPlugins("cursor").find((p) => p.name === "sample-plugin"); expect(plugin?.version).toBe("1.0.0"); expect(plugin?.files.size).toBeGreaterThan(0); + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"))).toBe( + "# Demo skill v2" + ); }); it("writes the updated plugin under the user-scope base dir, not the project root", async () => { @@ -120,9 +497,10 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { await makeUpdateUseCase(deps, registry).execute({ toolIds: ["cursor"], projectRoot: PROJECT_ROOT, + scope: "user", }); - const manifest = await deps.manifestRepo.load(); + const manifest = deps.userManifestRepo.getCurrent(); const plugin = manifest?.getPlugins("cursor").find((p) => p.name === "sample-plugin"); const written = [...(plugin?.files.keys() ?? [])]; expect(written.length).toBeGreaterThan(0); @@ -131,4 +509,271 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { expect(deps.fs.getFile(join(PROJECT_ROOT, relativePath))).toBeUndefined(); } }); + + it("does not rewrite or advance a plugin already at the fetched version", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const machine = deps.userManifestRepo.getCurrent(); + const stale = machine?.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin"); + if (machine === null || stale === undefined) throw new Error("fixture missing plugin"); + machine.updatePlugin("cursor", stale.withVersion("1.0.0")); + const savesBefore = deps.userManifestRepo.saveCount; + expect( + await makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toEqual([]); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(machine.getPlugins("cursor")[0]?.version).toBe("1.0.0"); + }); + + it("refuses an escaped old user file before writing a fetched build or changing its claim", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const foreign = "/foreign/sample-plugin"; + const dir = join(USER_PLUGINS_DIR, "sample-plugin"); + deps.fs.setSymlink(dir, foreign); + deps.fs.setFile(join(foreign, "skills/demo/SKILL.md"), "foreign bytes"); + await expect( + makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/unsafe recorded files/); + expect(deps.fs.getFile(join(foreign, "skills/demo/SKILL.md"))).toBe("foreign bytes"); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.version).toBe("0.0.1"); + }); + + it("refuses to overwrite a user-edited Cursor plugin file and retains its installed digest", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const path = join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"); + const before = deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]; + if (before === undefined) throw new Error("fixture missing plugin claim"); + deps.fs.setFile(path, "# User edited skill\n"); + const savesBefore = deps.userManifestRepo.saveCount; + await expect( + makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/edited.*SKILL.md|SKILL.md.*edited/); + expect(deps.fs.getFile(path)).toBe("# User edited skill\n"); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0].files).toEqual(before.files); + }); + + it("refuses a new built file that the user created after v1 without changing bytes or claims", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const newRelativePath = "sample-plugin/skills/demo/new.md"; + const destination = join(USER_PLUGINS_DIR, newRelativePath); + const machineBefore = deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]; + if (machineBefore === undefined) throw new Error("fixture missing plugin claim"); + const savesBefore = deps.userManifestRepo.saveCount; + deps.fs.setFile(join("/built/cursor/plugins", newRelativePath), "# Built v2\n"); + deps.fs.setFile(destination, "# User-created\n"); + + await expect( + makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/new.md.*untracked|untracked.*new.md/); + + expect(deps.fs.getFile(destination)).toBe("# User-created\n"); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]).toEqual(machineBefore); + }); + + it("preflights a Cursor user-file collision before any selected native host update", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const { machine, activator, nativeHost } = attachFutureSupportedNativeClaim(deps); + const newRelativePath = "sample-plugin/skills/demo/new.md"; + const destination = join(USER_PLUGINS_DIR, newRelativePath); + deps.fs.setFile(join("/built/cursor/plugins", newRelativePath), "# Built v2\n"); + deps.fs.setFile(destination, "# User-created\n"); + const claimBefore = machine.getNativeRegistrations("copilot")?.pluginClaims; + const savesBefore = deps.userManifestRepo.saveCount; + + await expect( + makeUpdateUseCase(deps, registry, nativeHost).execute({ + toolIds: ["copilot", "cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/new.md.*untracked|untracked.*new.md/); + + expect(activator.updatedPlugins).toEqual([]); + expect(deps.fs.getFile(destination)).toBe("# User-created\n"); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(machine.getNativeRegistrations("copilot")?.pluginClaims).toEqual(claimBefore); + }); + + it("refuses a tracked old path redirected outside the user boundary between plan and apply", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const machine = deps.userManifestRepo.getCurrent(); + const stale = machine?.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin"); + if (machine === null || stale === undefined) throw new Error("fixture missing plugin"); + const oldRelativePath = "sample-plugin/skills/obsolete/SKILL.md"; + deps.fs.setFile(join(USER_PLUGINS_DIR, oldRelativePath), "old owned bytes"); + machine.updatePlugin( + "cursor", + stale.withFiles( + new Map([...stale.files, [oldRelativePath, deps.hasher.hash("old owned bytes").value]]) + ) + ); + const updater = new UserPluginFileUpdater( + deps.fs, + new UserPluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), + deps.hasher, + { + ensureBuilt: fakeEnsureBuiltMarketplace(), + marketplaceRegistry: registry, + homedir: () => HOME, + } + ); + const plan = await updater.planUpdate( + machine.getPlugins("cursor")[0], + "cursor", + PROJECT_ROOT, + deps.logger + ); + if (plan === null) throw new Error("fixture did not plan a newer build"); + const foreign = "/foreign/obsolete"; + deps.fs.setSymlink(join(USER_PLUGINS_DIR, "sample-plugin/skills/obsolete"), foreign); + deps.fs.setFile(join(foreign, "SKILL.md"), "outside user bytes"); + const writeSpy = vi.spyOn(deps.fs, "writeFile"); + + await expect(updater.applyUpdate(plan, deps.logger)).rejects.toThrow(/unsafe recorded files/); + + expect(writeSpy).not.toHaveBeenCalled(); + expect(deps.fs.getFile(join(foreign, "SKILL.md"))).toBe("outside user bytes"); + expect(machine.getPlugins("cursor")[0]?.version).toBe("0.0.1"); + }); + + it("reports manual reconciliation when user-file I/O fails after an irreversible native update", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const { machine, activator, nativeHost } = attachFutureSupportedNativeClaim(deps); + const newRelativePath = "sample-plugin/skills/demo/new.md"; + const destination = join(USER_PLUGINS_DIR, newRelativePath); + deps.fs.setFile(join("/built/cursor/plugins", newRelativePath), "# Built v2\n"); + const writeFile = deps.fs.writeFile.bind(deps.fs); + const writeFailure = new Error("EACCES: injected destination write failure"); + vi.spyOn(deps.fs, "writeFile").mockImplementation(async (path, content) => { + if (path === destination) throw writeFailure; + return writeFile(path, content); + }); + const savesBefore = deps.userManifestRepo.saveCount; + + const operation = makeUpdateUseCase(deps, registry, nativeHost).execute({ + toolIds: ["copilot", "cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }); + await expect(operation).rejects.toThrow( + /native ref may already have been updated.*reconcile manually/ + ); + await expect(operation).rejects.toMatchObject({ cause: writeFailure }); + + expect(activator.updatedPlugins).toEqual(["native-plugin@aidd-catalog"]); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(machine.getPlugins("cursor")[0]?.version).toBe("0.0.1"); + expect(machine.getNativeRegistrations("copilot")?.pluginClaims).toEqual([ + { ref: "native-plugin@aidd-catalog", dependents: [PROJECT_ROOT] }, + ]); + }); + + it("prunes only old owned paths and keeps dependent roots when replacing the user files", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const machine = deps.userManifestRepo.getCurrent(); + const stale = machine?.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin"); + if (machine === null || stale === undefined) throw new Error("fixture missing plugin"); + const oldPath = "sample-plugin/skills/obsolete/SKILL.md"; + const oldAbsolute = join(USER_PLUGINS_DIR, oldPath); + deps.fs.setFile(oldAbsolute, "old owned bytes"); + machine.updatePlugin( + "cursor", + stale + .withFiles(new Map([...stale.files, [oldPath, deps.hasher.hash("old owned bytes").value]])) + .withDependents([PROJECT_ROOT, "/B"]) + ); + expect( + await makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toEqual(["sample-plugin"]); + expect(deps.fs.getFile(oldAbsolute)).toBeUndefined(); + const owned = deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]; + expect(owned?.dependents).toEqual([PROJECT_ROOT, "/B"]); + expect(owned?.files.has(oldPath)).toBe(false); + }); + + it("translates a local user plugin without taking an unrelated marketplace's built tree", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "sample-plugin", + "0.0.1", + { kind: "local", path: PLUGIN_FIXTURE }, + false, + "user" + ).withDependents([PROJECT_ROOT]) + ); + await deps.userManifestRepo.save(machine); + expect( + await makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toEqual(["sample-plugin"]); + const owned = deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]; + expect(owned?.version).toBe("1.0.0"); + expect(owned?.dependents).toEqual([PROJECT_ROOT]); + expect(owned?.files.size).toBeGreaterThan(0); + expect([...(owned?.files.keys() ?? [])].some((path) => path.includes("skills/hello"))).toBe( + true + ); + expect([...(owned?.files.keys() ?? [])].some((path) => path.includes("hooks/"))).toBe(false); + expect( + deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md")) + ).toBeUndefined(); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts index ddf998fce..c55d9e66b 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts @@ -74,7 +74,8 @@ async function installStaleGithubPlugin( deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: GIT_SUBDIR_SOURCE, toolIds: [toolId], diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts index a6750db11..6312d5143 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts @@ -48,7 +48,8 @@ async function setup( deps.hasher, deps.logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); const updateUseCase = new PluginUpdateUseCase( deps.fs, @@ -87,6 +88,42 @@ function installed(deps: Deps, name: string) { } describe("PluginUpdateUseCase", () => { + it("skips machine-owned Cursor plugins in an unnamed project sweep", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const { addUseCase, updateUseCase } = await setup(deps); + await addUseCase.execute({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + const machineBefore = deps.userManifestRepo.getCurrent()?.toJSON(); + expect(await updateUseCase.execute({ toolIds: ["cursor"], projectRoot: PROJECT_ROOT })).toEqual( + [] + ); + expect(deps.userManifestRepo.getCurrent()?.toJSON()).toEqual(machineBefore); + }); + + it("refuses an explicitly named Cursor plugin at project scope", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const { addUseCase, updateUseCase } = await setup(deps); + await addUseCase.execute({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + await expect( + updateUseCase.execute({ + pluginNames: ["sample-plugin"], + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/does not support scope 'project'/); + }); + describe("same version", () => { it("does not re-write files when version is equal", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts index 7bdedf56c..b7633554f 100644 --- a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts @@ -1,3 +1,4 @@ +import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { RestoreAllUseCase } from "../../../../src/contexts/framework/application/global/restore-all-use-case.js"; @@ -46,7 +47,8 @@ async function installPlugin( deps.hasher, deps.logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, toolIds: [toolId], @@ -140,6 +142,34 @@ describe("RestoreAllUseCase — the --force flag", () => { }); }); +describe("RestoreAllUseCase — an interactive run that ticks nothing", () => { + it("restores nothing: an empty selection is a decision, not the absence of one", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const manifest = await deps.manifestRepo.load(); + const tracked = manifest?.getToolFiles("claude") ?? []; + const trackedPath = join(PROJECT_ROOT, tracked[0].relativePath); + await deps.fs.writeFile(trackedPath, "EDITED OUTSIDE THE CLI"); + const prompter = new ScriptedPrompter([ScriptedPrompter.answer.checkbox([])]); + + const result = await makeRestoreAllUseCase( + deps, + new PluginDistributionReaderAdapter(deps.fs), + prompter + ).execute(PROJECT_ROOT, false, true); + + expect(deps.fs.getFile(trackedPath)).toBe("EDITED OUTSIDE THE CLI"); + expect(result).toStrictEqual({ + totalRestored: 0, + totalKept: 0, + pluginNamesRestored: [], + errors: [], + unrestorable: [], + nativeOnlyToolIds: [], + }); + }); +}); + describe("RestoreAllUseCase — plugin materialization", () => { it("restores a corrupted plugin file with exactly one materialization call (translate-mode: claude)", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); @@ -160,32 +190,27 @@ describe("RestoreAllUseCase — plugin materialization", () => { expect(count()).toBe(1); }); - it("restores a corrupted plugin file with exactly one materialization call (cursor — installScope:user tool)", async () => { - // A local-source install never reaches restoreViaBuiltTree — that path requires - // plugin.marketplace — so this exercises restoreViaTranslate for an installScope:"user" tool. + it("leaves a corrupted shared user plugin to explicit user-scope repair (cursor)", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "cursor"); await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); await installPlugin(deps, "cursor", new PluginDistributionReaderAdapter(deps.fs)); - const manifestAfterInstall = await deps.manifestRepo.load(); + const manifestAfterInstall = deps.userManifestRepo.getCurrent(); const plugin = manifestAfterInstall ?.getPlugins("cursor") .find((p) => p.name === "sample-plugin"); const trackedRelativePath = [...(plugin?.files.keys() ?? [])][0]; expect(trackedRelativePath).toBeDefined(); - // plugin.files keys are relativePath (see restoreViaTranslate); actual fs storage is - // keyed by the absolute path the file was written to. - const pluginFile = join(PROJECT_ROOT, trackedRelativePath as string); + const pluginFile = join(homedir(), ".cursor/plugins/local", trackedRelativePath as string); await deps.fs.writeFile(pluginFile, "CORRUPTED CONTENT"); - // Counting reader wired only from here — installPlugin's own read() must not count. const { reader, count } = countingReader(deps.fs); const useCase = makeRestoreAllUseCase(deps, reader, new OverwritePrompter(), true); await useCase.execute(PROJECT_ROOT, false, false); - expect(deps.fs.getFile(pluginFile)).not.toBe("CORRUPTED CONTENT"); - expect(count()).toBe(1); + expect(deps.fs.getFile(pluginFile)).toBe("CORRUPTED CONTENT"); + expect(count()).toBe(0); }); it("result.pluginNamesRestored lists the restored plugin exactly once", async () => { @@ -269,7 +294,8 @@ describe("RestoreAllUseCase — plugin materialization", () => { deps.hasher, deps.logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, toolIds: ["codex"], @@ -552,7 +578,7 @@ describe("RestoreAllUseCase — the interactive file picker", () => { expect(asked.map((o) => o.files)).toStrictEqual([[KEYBINDINGS]]); }); - it("forwards an empty selection as no file at all", async () => { + it("never delegates when the user ticked nothing: an empty selection is a decision", async () => { const deps = await vscodeProject(); await deps.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); const { asked, delegate } = recordingDelegate(); @@ -563,10 +589,10 @@ describe("RestoreAllUseCase — the interactive file picker", () => { true ); - expect(asked.map((o) => o.files)).toStrictEqual([[]]); + expect(asked).toStrictEqual([]); }); - it("asks nothing and selects nothing when no tracked entry drifted", async () => { + it("asks nothing and delegates with no selection when no tracked entry drifted", async () => { const deps = await vscodeProject(); const prompter = new CheckboxRecordingPrompter([KEYBINDINGS]); const { asked, delegate } = recordingDelegate(); @@ -574,6 +600,6 @@ describe("RestoreAllUseCase — the interactive file picker", () => { await restoreAllDelegatingTo(deps, prompter, delegate).execute(PROJECT_ROOT, false, true); expect(prompter.asks).toStrictEqual([]); - expect(asked.map((o) => o.files)).toStrictEqual([[]]); + expect(asked.map((o) => o.files)).toStrictEqual([undefined]); }); }); diff --git a/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts index 2580bc7af..72c8f48cb 100644 --- a/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts @@ -38,7 +38,8 @@ async function installPlugin( deps.hasher, deps.logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, toolIds: [toolId], diff --git a/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts b/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts index bd38645eb..125197399 100644 --- a/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts @@ -1,3 +1,4 @@ +import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; @@ -11,9 +12,7 @@ import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; -const HOME = "/home/u"; -/** Where cursor's PluginsCapability resolves user-scope plugin writes to. */ -const USER_PLUGINS_DIR = join(HOME, ".cursor/plugins/local"); +const USER_PLUGINS_DIR = join(homedir(), ".cursor/plugins/local"); const BUILT_SKILL = "/built/cursor/plugins/sample-plugin/skills/demo/SKILL.md"; const GIT_SUBDIR_SOURCE = { @@ -52,7 +51,7 @@ function makeRestoreUseCase( { ensureBuilt: fakeEnsureBuiltMarketplace(), marketplaceRegistry: registry, - homedir: () => HOME, + homedir, } ); } @@ -74,7 +73,8 @@ async function installMarketplacePlugin( deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: GIT_SUBDIR_SOURCE, toolIds: ["cursor"], @@ -86,13 +86,13 @@ async function installMarketplacePlugin( } describe("RestoreAllPluginsUseCase — built-tree materialization", () => { - it("re-materializes a marketplace plugin's files from the built tree at the user-scope dir", async () => { + it("does not re-materialize a shared user plugin during project restore", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "cursor"); const registry = await makeRegistry(); await installMarketplacePlugin(deps, registry); - const manifestBefore = await deps.manifestRepo.load(); + const manifestBefore = deps.userManifestRepo.getCurrent(); if (manifestBefore === null) throw new Error("manifest not found"); const installedRelPaths = [ ...(manifestBefore @@ -113,17 +113,17 @@ describe("RestoreAllPluginsUseCase — built-tree materialization", () => { fileFilter: null, }); - expect(result.pluginNames).toContain("sample-plugin"); - expect(result.totalFiles).toBeGreaterThan(0); + expect(result.pluginNames).not.toContain("sample-plugin"); + expect(result.totalFiles).toBe(0); for (const relativePath of installedRelPaths) { - expect(deps.fs.getFile(join(USER_PLUGINS_DIR, relativePath))).toBeDefined(); + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, relativePath))).toBeUndefined(); expect(deps.fs.getFile(join(PROJECT_ROOT, relativePath))).toBeUndefined(); } const plugins = manifest.getPlugins("cursor").filter((p) => p.name === "sample-plugin"); expect(plugins).toHaveLength(1); - expect([...plugins[0].files.keys()].sort()).toEqual([...installedRelPaths].sort()); + expect(plugins[0].files.size).toBe(0); }); it("reports zero files restored when a built-tree restore finds nothing drifted", async () => { @@ -135,16 +135,15 @@ describe("RestoreAllPluginsUseCase — built-tree materialization", () => { const manifest = await deps.manifestRepo.load(); if (manifest === null) throw new Error("manifest not found"); const installedRelPaths = [ - ...(manifest - .getPlugins("cursor") + ...(deps.userManifestRepo + .getCurrent() + ?.getPlugins("cursor") .find((p) => p.name === "sample-plugin") ?.files.keys() ?? []), ]; expect(installedRelPaths.length).toBeGreaterThan(0); const builtContent = deps.fs.getFile(BUILT_SKILL); if (builtContent === undefined) throw new Error("built fixture missing"); - // Seed the user-scope plugin dir with exactly what the built tree already - // materializes, so this restore has nothing left to change. for (const relativePath of installedRelPaths) { await deps.fs.writeFile(join(USER_PLUGINS_DIR, relativePath), builtContent); } @@ -156,6 +155,9 @@ describe("RestoreAllPluginsUseCase — built-tree materialization", () => { }); expect(result.totalFiles).toBe(0); + for (const relativePath of installedRelPaths) { + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, relativePath))).toBe(builtContent); + } }); it("keeps the manifest's single entry for the plugin after restore (no duplicate registration)", async () => { diff --git a/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts b/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts index a75c05162..85ea9d9b8 100644 --- a/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts @@ -73,7 +73,8 @@ async function installGithubPlugin( deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: GIT_SUBDIR_SOURCE, toolIds: ["claude"], diff --git a/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts b/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts index 673bf4101..d54f8d597 100644 --- a/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts @@ -48,6 +48,77 @@ describe("purgeAllNativeCaches", () => { }); describe("purgeNativeMarketplaceCache", () => { + it("keeps the cache and names the registry that still owns its tenant after a confirmed removal", async () => { + const path = join(CANDIDATE, "plugin.json"); + const fs = new InMemoryFileAdapter({ [path]: "still registered" }); + const logger = new CapturingLogger(); + const location = "/host/known_marketplaces.json"; + const reader = new FakeHostMarketplaceRegistryReader({ + location, + entries: new Map([[HOST_NAME, "/another-project/marketplace"]]), + }); + + await purgeNativeMarketplaceCache(fs, logger, reader, CACHE_ROOT, "claude", HOST_NAME, true); + + expect(fs.getFile(path)).toBe("still registered"); + expect(reader.reads).toBe(1); + expect(logger.infoMessages).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([ + `claude: cache for '${HOST_NAME}' left in place, ${location} still names it: ${CANDIDATE}`, + ]); + }); + + it("purges only its tenant once a readable registry no longer names it", async () => { + const neighborPath = join(CACHE_ROOT, "another-marketplace", "plugin.json"); + const fs = new InMemoryFileAdapter({ + [join(CANDIDATE, "plugin.json")]: "removed tenant", + [neighborPath]: "other tenant", + }); + const logger = new CapturingLogger(); + const reader = new FakeHostMarketplaceRegistryReader({ + location: "/host/known_marketplaces.json", + entries: new Map([["another-marketplace", "/other-project/marketplace"]]), + }); + + await purgeNativeMarketplaceCache(fs, logger, reader, CACHE_ROOT, "claude", HOST_NAME, true); + + expect(fs.listAll()).toStrictEqual([neighborPath.replaceAll("\\", "/")]); + expect(fs.getFile(neighborPath)).toBe("other tenant"); + expect(reader.reads).toBe(1); + expect(logger.warnMessages).toStrictEqual([]); + expect(logger.infoMessages).toStrictEqual([ + `claude: cache for '${HOST_NAME}' purged: ${CANDIDATE}`, + ]); + }); + + it("keeps its cache and reports the unreadable registry instead of treating it as empty", async () => { + const path = join(CANDIDATE, "plugin.json"); + const fs = new InMemoryFileAdapter({ [path]: "unproven ownership" }); + const logger = new CapturingLogger(); + const location = "/host/known_marketplaces.json"; + const reader = new FakeHostMarketplaceRegistryReader({ location, unreadable: "EACCES" }); + + await purgeNativeMarketplaceCache(fs, logger, reader, CACHE_ROOT, "claude", HOST_NAME, true); + + expect(fs.getFile(path)).toBe("unproven ownership"); + expect(reader.reads).toBe(1); + expect(logger.infoMessages).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([ + `claude: plugin cache left in place, its registry could not be read: ${location}`, + ]); + }); + + it("keeps a machine-global catalogue's bytes when project clean did not unregister it, even if a registry reader says absent", async () => { + const path = join(CANDIDATE, "plugin.json"); + const fs = new InMemoryFileAdapter({ [path]: "B still needs these bytes" }); + const logger = new CapturingLogger(); + const reader = new FakeHostMarketplaceRegistryReader({ + location: "/host/registry", + absent: true, + }); + await purgeNativeMarketplaceCache(fs, logger, reader, CACHE_ROOT, "claude", HOST_NAME, false); + expect(fs.getFile(path)).toBe("B still needs these bytes"); + }); it("names the cache path when its real location escapes the cache root", async () => { const fs = new InMemoryFileAdapter(); fs.setSymlink(CANDIDATE, "/elsewhere"); diff --git a/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts b/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts index 458a093e3..7c73ef9a0 100644 --- a/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts @@ -1,4 +1,5 @@ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -6,6 +7,10 @@ import { marketplaceCatalogProbePath, readMarketplaceCatalogIdentity, } from "../../../../../src/contexts/framework/application/shared/read-marketplace-catalog-identity.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; const DIR = "/marketplace"; @@ -16,6 +21,44 @@ function fsWithCatalog(catalog: unknown): InMemoryFileAdapter { } describe("readMarketplaceCatalogIdentity", () => { + it("answers no identity when the declared catalog does not exist", async () => { + expect( + await readMarketplaceCatalogIdentity(new InMemoryFileAdapter(), "claude", DIR) + ).toBeUndefined(); + }); + + it("answers no identity when the declared catalog cannot be read", async () => { + const fs = new FaultingFileAdapter({ + [CATALOG_PATH]: JSON.stringify({ name: "aidd-framework" }), + }); + fs.failOn("readFile", CATALOG_PATH, errnoError("EACCES")); + + expect(await readMarketplaceCatalogIdentity(fs, "claude", DIR)).toBeUndefined(); + }); + + it.each(["not JSON", "null", '{"name":'])( + "answers no identity for an unreadable catalog document: %s", + async (content) => { + const fs = new InMemoryFileAdapter({ [CATALOG_PATH]: content }); + + expect(await readMarketplaceCatalogIdentity(fs, "claude", DIR)).toBeUndefined(); + } + ); + + it("reads OpenCode's declared catalog path even when a Claude catalog is also present", async () => { + const fs = fsWithCatalog({ name: "other-catalog", plugins: [{ name: "other-plugin" }] }); + fs.setFile( + join(DIR, "opencode.json"), + JSON.stringify({ name: "opencode-catalog", plugins: [{ name: "opencode-plugin" }] }) + ); + + expect(marketplaceCatalogProbePath("opencode", DIR)).toBe(join(DIR, "opencode.json")); + expect(await readMarketplaceCatalogIdentity(fs, "opencode", DIR)).toStrictEqual({ + name: "opencode-catalog", + pluginNames: ["opencode-plugin"], + }); + }); + it("reads the name and plugin names the tool's own catalog file declares", async () => { const fs = fsWithCatalog({ name: "aidd-framework", plugins: [{ name: "a" }, { name: "b" }] }); diff --git a/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts b/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts index b19a2d229..49cd25d41 100644 --- a/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts @@ -1,8 +1,14 @@ import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { createHash } from "node:crypto"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { removeProjectHooks } from "../../../../../src/contexts/framework/application/shared/remove-project-hooks.js"; +import { describe, expect, it, vi } from "vitest"; +import { + assertProjectHooksRemovable, + assertProjectHooksUnchanged, + removeProjectHooks, +} from "../../../../../src/contexts/framework/application/shared/remove-project-hooks.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { errnoError, FaultingFileAdapter, @@ -23,11 +29,247 @@ function hooksFile(...plugins: string[]): string { return JSON.stringify({ version: 1, hooks: { preToolUse: plugins.map(entry) } }); } +function plugin(options: { entry?: boolean; script?: string } = {}): InstalledPlugin { + const command = entry(PLUGIN).command; + return InstalledPlugin.fromJSON({ + name: PLUGIN, + source: { kind: "local", path: "/src" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + projectHooks: { + entries: options.entry + ? [ + { + event: "preToolUse", + command, + digest: createHash("md5") + .update(JSON.stringify(entry(PLUGIN))) + .digest("hex"), + }, + ] + : [], + scripts: + options.script === undefined + ? {} + : { + [`.cursor/hooks/${PLUGIN}/pre.js`]: createHash("md5") + .update(options.script) + .digest("hex"), + }, + }, + }); +} + describe("removeProjectHooks", () => { + it.each(["entries", "scripts"] as const)( + "refuses a legacy install with only %s present before changing any files", + async (present) => { + const content = hooksFile(PLUGIN); + const fs = new InMemoryFileAdapter( + present === "entries" ? { [HOOKS_PATH]: content } : { [SCRIPT_PATH]: "legacy" } + ); + const legacy = InstalledPlugin.fromJSON({ ...plugin().toJSON(), projectHooks: undefined }); + + await expect(removeProjectHooks(fs, legacy, "cursor", PROJECT_ROOT)).rejects.toThrow( + `Cursor project hooks for '${PLUGIN}' have an unproven legacy install digest; detach refused.` + ); + + expect(fs.getFile(HOOKS_PATH)).toBe(present === "entries" ? content : undefined); + expect(fs.getFile(SCRIPT_PATH)).toBe(present === "scripts" ? "legacy" : undefined); + } + ); + + it.each(["not-a-digest", `x${"a".repeat(32)}`, `${"a".repeat(32)}x`])( + "refuses an unproven script digest %s before unmerging verified entries", + async (digest) => { + const script = "installed script"; + const content = hooksFile(PLUGIN, OTHER); + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content, [SCRIPT_PATH]: script }); + const installed = plugin({ entry: true }).toJSON(); + const invalid = InstalledPlugin.fromJSON({ + ...installed, + projectHooks: { + entries: installed.projectHooks?.entries ?? [], + scripts: { [`.cursor/hooks/${PLUGIN}/pre.js`]: digest }, + }, + }); + + await expect(removeProjectHooks(fs, invalid, "cursor", PROJECT_ROOT)).rejects.toThrow( + `Cursor hook script '.cursor/hooks/${PLUGIN}/pre.js' has an unproven install digest; detach refused.` + ); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + expect(fs.getFile(SCRIPT_PATH)).toBe(script); + } + ); + + it.each([ + ".cursor/hooks/aidd-dev/pre.js", + "/foreign/pre.js", + ".cursor/hooks/aidd-context/../aidd-dev/pre.js", + ".cursor/hooks/aidd-context/./pre.js", + ".cursor/hooks/aidd-context/nested\\pre.js", + ])("refuses an unproven recorded script path %s before unmerging hooks", async (path) => { + const content = hooksFile(PLUGIN, OTHER); + const script = "installed script"; + const fs = new InMemoryFileAdapter({ + [HOOKS_PATH]: content, + [join(PROJECT_ROOT, path)]: script, + }); + const installed = plugin({ entry: true }).toJSON(); + const invalid = InstalledPlugin.fromJSON({ + ...installed, + projectHooks: { + entries: installed.projectHooks?.entries ?? [], + scripts: { [path]: createHash("md5").update(script).digest("hex") }, + }, + }); + + await expect(removeProjectHooks(fs, invalid, "cursor", PROJECT_ROOT)).rejects.toThrow( + `Cursor hook script path '${path}' has unproven provenance; detach refused.` + ); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + expect(fs.getFile(join(PROJECT_ROOT, path))).toBe(script); + }); + + it.each([ + '{ "version": 1, "hooks": {} }', + '{ "version": 1 }', + JSON.stringify({ hooks: { preToolUse: [{ command: 42 }, entry(OTHER)] } }), + ])("preserves a hooks file with no contribution byte-for-byte: %s", async (content) => { + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content }); + + expect(await removeProjectHooks(fs, plugin(), "cursor", PROJECT_ROOT)).toBe(false); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + }); + + it("preflights reordered recorded entries without changing hooks or scripts", async () => { + const first = { ...entry(PLUGIN), timeout: 25 }; + const second = { command: `node ./.cursor/hooks/${PLUGIN}/post.js` }; + const content = JSON.stringify({ + hooks: { postToolUse: [second], preToolUse: [entry(OTHER), first] }, + }); + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content, [SCRIPT_PATH]: "script" }); + const installed = InstalledPlugin.fromJSON({ + ...plugin().toJSON(), + projectHooks: { + entries: [ + { + event: "preToolUse", + command: first.command, + digest: createHash("md5").update(JSON.stringify(first)).digest("hex"), + }, + { + event: "postToolUse", + command: second.command, + digest: createHash("md5").update(JSON.stringify(second)).digest("hex"), + }, + ], + scripts: {}, + }, + }); + + await expect( + assertProjectHooksRemovable(fs, installed, "cursor", PROJECT_ROOT) + ).resolves.toBeUndefined(); + await expect( + assertProjectHooksUnchanged(fs, PLUGIN, installed.projectHooks, "cursor", PROJECT_ROOT) + ).resolves.toStrictEqual({ existing: content }); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + expect(fs.getFile(SCRIPT_PATH)).toBe("script"); + expect(await removeProjectHooks(fs, installed, "cursor", PROJECT_ROOT)).toBe(true); + expect(JSON.parse(fs.getFile(HOOKS_PATH) ?? "null").hooks).toStrictEqual({ + preToolUse: [entry(OTHER)], + }); + }); + + it("refuses a removed recorded entry before removing its script", async () => { + const content = hooksFile(OTHER); + const script = "installed script"; + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content, [SCRIPT_PATH]: script }); + + await expect( + assertProjectHooksRemovable(fs, plugin({ entry: true, script }), "cursor", PROJECT_ROOT) + ).rejects.toThrow(/edited after install/); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + expect(fs.getFile(SCRIPT_PATH)).toBe(script); + }); + + it("does not recreate a tracked script already removed by the user", async () => { + const content = hooksFile(OTHER); + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content }); + + expect( + await removeProjectHooks(fs, plugin({ script: "installed script" }), "cursor", PROJECT_ROOT) + ).toBe(false); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + expect(fs.getFile(SCRIPT_PATH)).toBeUndefined(); + }); + + it("allows a legacy install with no project contribution and returns the existing hooks during preflight", async () => { + const content = hooksFile(OTHER); + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content }); + + await expect( + assertProjectHooksUnchanged(fs, PLUGIN, undefined, "cursor", PROJECT_ROOT) + ).resolves.toStrictEqual({ existing: content }); + await expect( + assertProjectHooksUnchanged(fs, PLUGIN, undefined, "opencode", PROJECT_ROOT) + ).resolves.toStrictEqual({ existing: null }); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + }); + it("refuses a hooks.json symlink outside the project even when its entries match the install digest", async () => { + const content = hooksFile(PLUGIN, OTHER); + const foreignPath = "/foreign/hooks.json"; + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content, [foreignPath]: content }); + fs.setSymlink(HOOKS_PATH, foreignPath); + const write = vi.spyOn(fs, "writeFile"); + const remove = vi.spyOn(fs, "deleteFile"); + + await expect( + removeProjectHooks(fs, plugin({ entry: true }), "cursor", PROJECT_ROOT) + ).rejects.toThrow(/outside.*project|escapes.*project/); + + expect(write).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + expect(fs.getFile(foreignPath)).toBe(content); + }); + + it("refuses a hook script parent symlink outside the project despite matching bytes and digest", async () => { + const script = "module.exports = () => {};"; + const foreignDir = "/foreign/script-dir"; + const foreignPath = join(foreignDir, "pre.js"); + const fs = new InMemoryFileAdapter({ + [SCRIPT_PATH]: script, + [foreignPath]: script, + }); + fs.setSymlink(join(PROJECT_ROOT, ".cursor", "hooks", PLUGIN), foreignDir); + const write = vi.spyOn(fs, "writeFile"); + const remove = vi.spyOn(fs, "deleteFile"); + + await expect( + removeProjectHooks(fs, plugin({ script }), "cursor", PROJECT_ROOT) + ).rejects.toThrow(/outside.*project|escapes.*project/); + + expect(write).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + expect(fs.getFile(foreignPath)).toBe(script); + }); + it("leaves a tool that keeps hooks in its plugin directory untouched and reports nothing undone", async () => { const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: hooksFile(PLUGIN) }); - expect(await removeProjectHooks(fs, PLUGIN, "opencode", PROJECT_ROOT)).toBe(false); + expect(await removeProjectHooks(fs, plugin({ entry: true }), "opencode", PROJECT_ROOT)).toBe( + false + ); expect(fs.getFile(HOOKS_PATH)).toBe(hooksFile(PLUGIN)); }); @@ -35,25 +277,69 @@ describe("removeProjectHooks", () => { const fs = new FaultingFileAdapter(); fs.failOn("deleteDirectory", `${PROJECT_ROOT}/.cursor/hooks/${PLUGIN}/`, errnoError("EPERM")); - expect(await removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT)).toBe(false); + expect(await removeProjectHooks(fs, plugin(), "cursor", PROJECT_ROOT)).toBe(false); }); - it("unmerges only this plugin's entries and reports something undone when just the hooks file exists", async () => { - const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: hooksFile(PLUGIN, OTHER) }); + it("removes only recorded entries and scripts while preserving untracked files in the same plugin directory", async () => { + const script = "module.exports = () => {};"; + const notePath = join(PROJECT_ROOT, ".cursor", "hooks", PLUGIN, "user-note.md"); + const otherScript = join(PROJECT_ROOT, ".cursor", "hooks", OTHER, "pre.js"); + const fs = new InMemoryFileAdapter({ + [HOOKS_PATH]: hooksFile(PLUGIN, OTHER), + [SCRIPT_PATH]: script, + [notePath]: "user note", + [otherScript]: "other plugin script", + }); - const undone = await removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT); + const undone = await removeProjectHooks( + fs, + plugin({ entry: true, script }), + "cursor", + PROJECT_ROOT + ); expect(undone).toBe(true); expect(JSON.parse(fs.getFile(HOOKS_PATH) ?? "null")).toStrictEqual({ version: 1, hooks: { preToolUse: [entry(OTHER)] }, }); + expect(fs.getFile(SCRIPT_PATH)).toBeUndefined(); + expect(fs.getFile(notePath)).toBe("user note"); + expect(fs.getFile(otherScript)).toBe("other plugin script"); }); + it.each(["metadata", "event"] as const)( + "refuses changed hook %s despite an unchanged command, before deleting any tracked script", + async (change) => { + const script = "module.exports = () => {};"; + const current = JSON.stringify({ + version: 1, + hooks: { + [change === "event" ? "postToolUse" : "preToolUse"]: [ + change === "metadata" ? { ...entry(PLUGIN), timeout: 99 } : entry(PLUGIN), + entry(OTHER), + ], + }, + }); + const fs = new InMemoryFileAdapter({ + [HOOKS_PATH]: current, + [SCRIPT_PATH]: script, + }); + + await expect( + removeProjectHooks(fs, plugin({ entry: true, script }), "cursor", PROJECT_ROOT) + ).rejects.toThrow(/edited after install/); + + expect(fs.getFile(HOOKS_PATH)).toBe(current); + expect(fs.getFile(SCRIPT_PATH)).toBe(script); + } + ); + it("removes the script directory and reports something undone when only the scripts exist", async () => { - const fs = new InMemoryFileAdapter({ [SCRIPT_PATH]: "module.exports = () => {};" }); + const script = "module.exports = () => {};"; + const fs = new InMemoryFileAdapter({ [SCRIPT_PATH]: script }); - const undone = await removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT); + const undone = await removeProjectHooks(fs, plugin({ script }), "cursor", PROJECT_ROOT); expect(undone).toBe(true); expect(fs.listAll()).toStrictEqual([]); @@ -63,8 +349,28 @@ describe("removeProjectHooks", () => { const fs = new FaultingFileAdapter(); fs.failOn("readFile", HOOKS_PATH, errnoError("EACCES")); - await expect(removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT)).rejects.toThrow( + await expect(removeProjectHooks(fs, plugin(), "cursor", PROJECT_ROOT)).rejects.toThrow( "EACCES: planted by the test" ); }); + + it("refuses a legacy hook with no install digest instead of deleting by plugin name", async () => { + const fs = new InMemoryFileAdapter({ + [HOOKS_PATH]: hooksFile(PLUGIN), + [SCRIPT_PATH]: "legacy", + }); + const legacy = InstalledPlugin.fromJSON({ + name: PLUGIN, + source: { kind: "local", path: "/src" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + }); + await expect(removeProjectHooks(fs, legacy, "cursor", PROJECT_ROOT)).rejects.toThrow( + /unproven/ + ); + expect(fs.getFile(HOOKS_PATH)).toBe(hooksFile(PLUGIN)); + expect(fs.getFile(SCRIPT_PATH)).toBe("legacy"); + }); }); diff --git a/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts b/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts index b032a1c86..238918d43 100644 --- a/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts @@ -1,9 +1,14 @@ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { createHash } from "node:crypto"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { userScopeFilesSafeToDelete } from "../../../../../src/contexts/framework/application/shared/user-scope-plugin-files.js"; +import { describe, expect, it, vi } from "vitest"; +import { + assertUserScopeWriteBoundary, + userScopeFilesSafeToDelete, +} from "../../../../../src/contexts/framework/application/shared/user-scope-plugin-files.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { FileHash, InstallationFile } from "../../../../../src/kernel/file.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { errnoError, @@ -13,7 +18,8 @@ import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-ad const HOME = "/home/u"; const BOUNDARY = join(HOME, ".cursor", "plugins", "local"); -const HASH = "abc123abc123abc123abc123abc123ab"; +const CONTENT = "owned bytes"; +const HASH = createHash("md5").update(CONTENT).digest("hex"); function plugin(files: Record): InstalledPlugin { return InstalledPlugin.fromJSON({ @@ -80,6 +86,7 @@ describe("userScopeFilesSafeToDelete", () => { const fs = new FaultingFileAdapter(); fs.failOn("realpath", join(BOUNDARY, "aidd-test", "gone.md"), errnoError("ENOENT")); const logger = new CapturingLogger(); + fs.setFile(join(BOUNDARY, "aidd-test", "a.md"), CONTENT); const safe = await userScopeFilesSafeToDelete( fs, @@ -109,4 +116,188 @@ describe("userScopeFilesSafeToDelete", () => { ) ).rejects.toThrow("EACCES: planted by the test"); }); + + it("refuses a user-edited file even though its path remains within the scope", async () => { + const path = join(BOUNDARY, "aidd-test", "plugin.json"); + const fs = new InMemoryFileAdapter({ [path]: "user-edited bytes" }); + await expect( + userScopeFilesSafeToDelete( + fs, + new CapturingLogger(), + plugin({ "aidd-test/plugin.json": HASH }), + "cursor", + HOME + ) + ).rejects.toThrow(/edited.*plugin.json|plugin.json.*edited/); + expect(fs.getFile(path)).toBe("user-edited bytes"); + }); + + it("refuses an unproven legacy digest and a failed content read", async () => { + const path = join(BOUNDARY, "aidd-test", "script.js"); + const fs = new FaultingFileAdapter(); + fs.setFile(path, CONTENT); + await expect( + userScopeFilesSafeToDelete( + fs, + new CapturingLogger(), + plugin({ "aidd-test/script.js": "" }), + "cursor", + HOME + ) + ).rejects.toThrow(/unproven.*script.js|script.js.*unproven/); + fs.failOn("readFile", path, errnoError("EACCES")); + await expect( + userScopeFilesSafeToDelete( + fs, + new CapturingLogger(), + plugin({ "aidd-test/script.js": HASH }), + "cursor", + HOME + ) + ).rejects.toThrow(/EACCES/); + }); +}); + +describe("assertUserScopeWriteBoundary", () => { + const file = (relativePath: string) => + new InstallationFile({ + relativePath, + content: "new bytes", + hash: new FileHash("a".repeat(32)), + }); + + it("refuses a tool without a declared user plugins directory", async () => { + await expect( + assertUserScopeWriteBoundary( + new InMemoryFileAdapter(), + "claude", + "aidd-test", + [file("aidd-test/a.md")], + HOME + ) + ).rejects.toThrow(/no user plugins directory/); + }); + + it.each([BOUNDARY, join(BOUNDARY, "aidd-test")])( + "propagates an unreadable write boundary at %s", + async (path) => { + const fs = new FaultingFileAdapter(); + const error = errnoError("EACCES"); + fs.failOn("realpath", path, error); + + await expect( + assertUserScopeWriteBoundary(fs, "cursor", "aidd-test", [file("aidd-test/a.md")], HOME) + ).rejects.toBe(error); + } + ); + + it("allows missing nested parents only after resolving an existing safe ancestor", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", join(BOUNDARY, "aidd-test/skills/new"), errnoError("ENOENT")); + fs.failOn("realpath", join(BOUNDARY, "aidd-test/skills"), errnoError("ENOENT")); + + await expect( + assertUserScopeWriteBoundary( + fs, + "cursor", + "aidd-test", + [file("aidd-test/skills/new/SKILL.md")], + HOME + ) + ).resolves.toBeUndefined(); + }); + + it("refuses a plugin directory that disappears after the initial boundary check", async () => { + const fs = new InMemoryFileAdapter(); + const pluginDir = join(BOUNDARY, "aidd-test"); + let pluginReads = 0; + vi.spyOn(fs, "realpath").mockImplementation(async (path) => { + if (path === BOUNDARY) return path; + if (path === pluginDir && ++pluginReads === 1) return path; + throw errnoError("ENOENT"); + }); + + await expect( + assertUserScopeWriteBoundary( + fs, + "cursor", + "aidd-test", + [file("aidd-test/skills/new/SKILL.md")], + HOME + ) + ).rejects.toThrow(/directory disappeared during update/); + }); + + it("allows a new file only within the owned plugin directory", async () => { + const fs = new InMemoryFileAdapter(); + await expect( + assertUserScopeWriteBoundary( + fs, + "cursor", + "aidd-test", + [file("aidd-test/skills/demo/SKILL.md")], + HOME + ) + ).resolves.toBeUndefined(); + }); + + it.each([ + "../escape.md", + "aidd-test/../escape.md", + "other-plugin/SKILL.md", + "aidd-test\\skills\\SKILL.md", + "/absolute/escape.md", + ])("refuses the untrusted machine update path %s", async (relativePath) => { + await expect( + assertUserScopeWriteBoundary( + new InMemoryFileAdapter(), + "cursor", + "aidd-test", + [file(relativePath)], + HOME + ) + ).rejects.toThrow(/escapes its plugin directory/); + }); + + it("refuses a plugin directory symlink that resolves outside its user-scope base", async () => { + const fs = new InMemoryFileAdapter(); + fs.setSymlink(join(BOUNDARY, "aidd-test"), "/foreign/aidd-test"); + await expect( + assertUserScopeWriteBoundary( + fs, + "cursor", + "aidd-test", + [file("aidd-test/skills/demo/SKILL.md")], + HOME + ) + ).rejects.toThrow(/directory is not safely inside/); + }); + + it("refuses a nested parent symlink before writing new bytes", async () => { + const fs = new InMemoryFileAdapter(); + fs.setSymlink(join(BOUNDARY, "aidd-test/skills"), "/foreign/skills"); + await expect( + assertUserScopeWriteBoundary( + fs, + "cursor", + "aidd-test", + [file("aidd-test/skills/demo/SKILL.md")], + HOME + ) + ).rejects.toThrow(/update parent.*escapes/); + }); + + it("refuses an absent plugin directory rather than inventing a safe write base", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", join(BOUNDARY, "aidd-test"), errnoError("ENOENT")); + await expect( + assertUserScopeWriteBoundary( + fs, + "cursor", + "aidd-test", + [file("aidd-test/skills/demo/SKILL.md")], + HOME + ) + ).rejects.toThrow(/directory is not safely inside/); + }); }); diff --git a/cli/tests/contexts/framework/application/uninstall-plugin-native-claims.integration.test.ts b/cli/tests/contexts/framework/application/uninstall-plugin-native-claims.integration.test.ts new file mode 100644 index 000000000..2cf9154ea --- /dev/null +++ b/cli/tests/contexts/framework/application/uninstall-plugin-native-claims.integration.test.ts @@ -0,0 +1,304 @@ +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { describe, expect, it } from "vitest"; +import { UninstallPluginUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-plugin-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { errnoError, FaultingFileAdapter } from "../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; + +const REF = "sample-plugin@real-catalog"; +const OTHER = "other-plugin@other-catalog"; + +function fixture(scope: "project" | "user" = "project") { + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: scope === "user" ? { "sample-plugin/skills/demo.md": "abc" } : {}, + scope, + marketplace: "project-alias", + }) + ); + project.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { alias: "project-alias", hostName: "real-catalog" }, + { alias: "other-alias", hostName: "other-catalog" }, + ], + pluginRefs: [REF, OTHER], + }); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: "project-alias", hostName: "real-catalog" }], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: ["/A", "/B"] }], + }); + return { + projectRepo: new InMemoryManifestRepository(project), + machineRepo: new InMemoryManifestRepository(machine), + fs: new InMemoryFileAdapter(), + }; +} + +describe("project plugin uninstall against shared native host ownership", () => { + it("retains A's Cursor projection and shared claim when local hooks are unreadable", async () => { + const pluginName = "sample-plugin"; + const hooksPath = join("/A", ".cursor/hooks.json"); + const hooks = JSON.stringify({ + version: 1, + hooks: { preToolUse: [{ command: `node ./.cursor/hooks/${pluginName}/pre.js` }] }, + }); + const fs = new FaultingFileAdapter(); + fs.setFile(hooksPath, hooks); + fs.setFile(join("/A", `.cursor/hooks/${pluginName}/pre.js`), "A's hook"); + fs.setFile(join("/B", ".cursor/hooks.json"), hooks); + const globalMcp = join(homedir(), `.cursor/plugins/local/${pluginName}/mcp.json`); + fs.setFile(globalMcp, "B's MCP"); + fs.failOn("readFile", hooksPath, errnoError("EACCES")); + const project = Manifest.create(); + project.addTool("cursor", "1.0.0", []); + project.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + }) + ); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + dependents: ["/A", "/B"], + }) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(machine); + const useCase = new UninstallPluginUseCase(fs, projectRepo, machineRepo); + + await expect( + useCase.execute({ pluginName, toolIds: ["cursor"], projectRoot: "/A" }) + ).rejects.toThrow(/EACCES/); + + expect(projectRepo.saveCount).toBe(0); + expect(projectRepo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + expect(machineRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/A", "/B"]); + expect(fs.getFile(hooksPath)).toBe(hooks); + expect(fs.getFile(globalMcp)).toBe("B's MCP"); + }); + + it("removes only A's Cursor project hooks before detaching A, preserving B and the global MCP", async () => { + const pluginName = "sample-plugin"; + const hooks = JSON.stringify({ + version: 1, + hooks: { preToolUse: [{ command: `node ./.cursor/hooks/${pluginName}/pre.js` }] }, + }); + const aHooks = join("/A", ".cursor/hooks.json"); + const bHooks = join("/B", ".cursor/hooks.json"); + const aScript = join("/A", `.cursor/hooks/${pluginName}/pre.js`); + const bScript = join("/B", `.cursor/hooks/${pluginName}/pre.js`); + const globalMcp = join(homedir(), `.cursor/plugins/local/${pluginName}/mcp.json`); + const fs = new InMemoryFileAdapter({ + [aHooks]: hooks, + [bHooks]: hooks, + [aScript]: "A's hook", + [bScript]: "B's hook", + [globalMcp]: "B's MCP", + }); + const project = Manifest.create(); + project.addTool("cursor", "1.0.0", []); + project.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + marketplace: "project-alias", + projectHooks: { + entries: [ + { + event: "preToolUse", + command: `node ./.cursor/hooks/${pluginName}/pre.js`, + digest: createHash("md5") + .update(JSON.stringify({ command: `node ./.cursor/hooks/${pluginName}/pre.js` })) + .digest("hex"), + }, + ], + scripts: { + [`.cursor/hooks/${pluginName}/pre.js`]: createHash("md5") + .update("A's hook") + .digest("hex"), + }, + }, + }) + ); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + dependents: ["/A", "/B"], + }) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(machine); + const useCase = new UninstallPluginUseCase(fs, projectRepo, machineRepo); + + await useCase.execute({ pluginName, toolIds: ["cursor"], projectRoot: "/A" }); + + expect(fs.getFile(aHooks)).toBeUndefined(); + expect(fs.getFile(aScript)).toBeUndefined(); + expect(fs.getFile(bHooks)).toBe(hooks); + expect(fs.getFile(bScript)).toBe("B's hook"); + expect(fs.getFile(globalMcp)).toBe("B's MCP"); + expect(projectRepo.getCurrent()?.getPlugins("cursor")).toEqual([]); + expect(machineRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/B"]); + }); + + it("does not detach a same-name Cursor user claim for a project-scope plugin", async () => { + const project = Manifest.create(); + project.addTool("cursor", "1.0.0", []); + project.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "sample-plugin", + "1.0.0", + { kind: "local", path: "/fixture" }, + false, + "project" + ) + ); + project.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "other-plugin", + "1.0.0", + { kind: "local", path: "/fixture" }, + false, + "user" + ) + ); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "sample-plugin", + "1.0.0", + { kind: "local", path: "/unrelated" }, + false, + "user" + ).withDependents(["/A", "/B"]) + ); + const machineRepo = new InMemoryManifestRepository(machine); + const useCase = new UninstallPluginUseCase( + new InMemoryFileAdapter(), + new InMemoryManifestRepository(project), + machineRepo + ); + + await useCase.execute({ pluginName: "sample-plugin", toolIds: ["cursor"], projectRoot: "/A" }); + expect(machineRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/A", "/B"]); + expect(machineRepo.saveCount).toBe(0); + }); + + it("drops A's exact projection and claim, leaving B and another catalogue intact", async () => { + const f = fixture(); + const useCase = new UninstallPluginUseCase(f.fs, f.projectRepo, f.machineRepo); + expect( + await useCase.execute({ pluginName: "sample-plugin", toolIds: ["codex"], projectRoot: "/A" }) + ).toEqual([{ toolId: "codex", fileCount: 0, deletedFiles: [] }]); + expect(f.projectRepo.getCurrent()?.getPlugins("codex")).toEqual([]); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + OTHER, + ]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/B"] }, + ]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([REF]); + }); + + it("uninstalls a project projection but leaves machine-owned user plugin bytes for B", async () => { + const f = fixture("user"); + const userPath = join("/user-home/.codex/plugins/sample-plugin/skills/demo.md"); + f.fs.setFile(userPath, "B's machine bytes"); + const useCase = new UninstallPluginUseCase(f.fs, f.projectRepo, f.machineRepo); + expect( + await useCase.execute({ pluginName: "sample-plugin", toolIds: [], projectRoot: "/A" }) + ).toEqual([{ toolId: "codex", fileCount: 0, deletedFiles: [] }]); + expect(f.fs.getFile(userPath)).toBe("B's machine bytes"); + expect( + f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims?.[0]?.dependents + ).toEqual(["/B"]); + }); + + it("never detaches a shared claim when local file deletion fails", async () => { + class RefusingFileAdapter extends InMemoryFileAdapter { + override async deleteFile(): Promise { + throw new Error("local delete failed"); + } + } + const f = fixture(); + const plugin = f.projectRepo.getCurrent()?.getPlugins("codex")[0]; + const project = f.projectRepo.getCurrent(); + if (project === null || plugin === undefined) throw new Error("fixture missing plugin"); + project.updatePlugin("codex", plugin.withFiles(new Map([[".codex/skills/demo.md", "abc"]]))); + const useCase = new UninstallPluginUseCase( + new RefusingFileAdapter(), + f.projectRepo, + f.machineRepo + ); + await expect( + useCase.execute({ pluginName: "sample-plugin", toolIds: ["codex"], projectRoot: "/A" }) + ).rejects.toThrow(/local delete failed/); + expect( + f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims?.[0]?.dependents + ).toEqual(["/A", "/B"]); + expect(f.machineRepo.saveCount).toBe(0); + }); + + it("does not detach any machine claim for a missing project plugin", async () => { + const f = fixture(); + await expect( + new UninstallPluginUseCase(f.fs, f.projectRepo, f.machineRepo).execute({ + pluginName: "foreign-plugin", + toolIds: ["codex"], + projectRoot: "/A", + }) + ).rejects.toThrow(/not installed/i); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts index 34f77639f..4412ee454 100644 --- a/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts @@ -1,7 +1,9 @@ +import { createHash } from "node:crypto"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { PluginAddUseCase } from "../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; import { UninstallPluginUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-plugin-use-case.js"; import { UninstallUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-use-case.js"; @@ -11,6 +13,7 @@ import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framew import { NoManifestError, PluginNotFoundError } from "../../../../src/kernel/errors.js"; import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { errnoError, FaultingFileAdapter } from "../../../helpers/ports/faulting-file-adapter.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; @@ -34,7 +37,8 @@ describe("UninstallUseCase — plugin scope", () => { deps.hasher, deps.logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, toolIds: ["claude"], @@ -48,7 +52,6 @@ describe("UninstallUseCase — plugin scope", () => { await new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ toolIds: [], projectRoot: PROJECT_ROOT, - mcpFilter: [], pluginName: "sample-plugin", }); @@ -65,7 +68,6 @@ describe("UninstallUseCase — plugin scope", () => { new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ toolIds: [], projectRoot: PROJECT_ROOT, - mcpFilter: [], pluginName: "nonexistent", }) ).rejects.toThrow(PluginNotFoundError); @@ -73,6 +75,147 @@ describe("UninstallUseCase — plugin scope", () => { }); describe("UninstallPluginUseCase — which plugin, on which tools", () => { + it.each([ + { name: "missing", error: null }, + { name: "unreadable", error: errnoError("EACCES") }, + ])("handles an $name OpenCode MCP config without losing the project record", async (scenario) => { + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + mine: createHash("md5") + .update(JSON.stringify({ type: "local" })) + .digest("hex"), + }, + scope: "project", + }) + ); + const fs = new FaultingFileAdapter(); + if (scenario.error !== null) + fs.failOn("readFile", join(PROJECT_ROOT, "opencode.json"), scenario.error); + const repo = new InMemoryManifestRepository(manifest); + const operation = new UninstallPluginUseCase(fs, repo).execute({ + pluginName: "sample", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + }); + + if (scenario.error === null) { + await expect(operation).resolves.toEqual([ + { toolId: "opencode", fileCount: 0, deletedFiles: [] }, + ]); + expect(repo.getCurrent()?.getPlugins("opencode")).toEqual([]); + } else { + await expect(operation).rejects.toThrow(/EACCES/); + expect(repo.getCurrent()?.getPlugins("opencode")).toHaveLength(1); + expect(repo.saveCount).toBe(0); + } + }); + + it("retains the OpenCode plugin projection when its local MCP config cannot be rewritten", async () => { + const mcpPath = join(PROJECT_ROOT, "opencode.json"); + const content = JSON.stringify({ mcp: { mine: { type: "local" } } }); + class RefusingMcpWriteAdapter extends InMemoryFileAdapter { + override async writeFile(path: string, value: string): Promise { + if (path === mcpPath) throw new Error("MCP write refused"); + return super.writeFile(path, value); + } + } + const fs = new RefusingMcpWriteAdapter({ [mcpPath]: content }); + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + mine: createHash("md5") + .update(JSON.stringify({ type: "local" })) + .digest("hex"), + }, + scope: "project", + }) + ); + const repo = new InMemoryManifestRepository(manifest); + + await expect( + new UninstallPluginUseCase(fs, repo).execute({ + pluginName: "sample", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/MCP write refused/); + + expect(repo.saveCount).toBe(0); + expect(repo.getCurrent()?.getPlugins("opencode")).toHaveLength(1); + expect(fs.getFile(mcpPath)).toBe(content); + }); + + it("unmerges only the installed plugin's OpenCode MCP contribution", async () => { + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + mine: createHash("md5") + .update(JSON.stringify({ type: "local" })) + .digest("hex"), + }, + scope: "project", + }) + ); + const mcpPath = join(PROJECT_ROOT, "opencode.json"); + const fs = new InMemoryFileAdapter({ + [mcpPath]: JSON.stringify({ mcp: { mine: { type: "local" }, theirs: { type: "remote" } } }), + }); + const repo = new InMemoryManifestRepository(manifest); + + fs.setFile( + mcpPath, + JSON.stringify({ mcp: { mine: { type: "user-edited" }, theirs: { type: "remote" } } }) + ); + await expect( + new UninstallPluginUseCase(fs, repo).execute({ + pluginName: "sample", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/edited/); + expect(repo.getCurrent()?.getPlugins("opencode")).toHaveLength(1); + fs.setFile( + mcpPath, + JSON.stringify({ mcp: { mine: { type: "local" }, theirs: { type: "remote" } } }) + ); + + await new UninstallPluginUseCase(fs, repo).execute({ + pluginName: "sample", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + }); + + expect(JSON.parse(fs.getFile(mcpPath) ?? "null")).toEqual({ + mcp: { theirs: { type: "remote" } }, + }); + expect((await repo.load())?.getPlugins("opencode")).toEqual([]); + }); + const HASH = "abc123abc123abc123abc123abc123ab"; function pluginNamed(name: string): InstalledPlugin { diff --git a/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts index d8340a1a3..8786cc45e 100644 --- a/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts @@ -32,7 +32,7 @@ class RecordingFileAdapter extends InMemoryFileAdapter { const PLUGIN_KEY = "aidd-context/commands/hello.md"; describe("UninstallToolsUseCase — cursor plugin file (user-scope)", () => { - it("deletes the plugin's file from its resolved home directory, not projectRoot", async () => { + it("does not delete a machine-owned user-scope plugin while uninstalling a project tool", async () => { const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); manifest.addPlugin( @@ -53,7 +53,7 @@ describe("UninstallToolsUseCase — cursor plugin file (user-scope)", () => { expect( fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", PLUGIN_KEY))) - ).toBe(true); + ).toBe(false); expect(fs.deletedPaths).not.toContain(join(PROJECT_ROOT, PLUGIN_KEY)); }); }); @@ -100,6 +100,35 @@ function remove( } describe("UninstallToolsUseCase — regular files", () => { + it("removes all project-scoped plugin files while preserving unrelated plugin content", async () => { + const first = ".claude/plugins/project-plugin/skills/demo/SKILL.md"; + const second = ".claude/plugins/project-plugin/commands/demo.md"; + const neighbor = ".codex/plugins/other-plugin/skills/demo/SKILL.md"; + const target = project({ [first]: "skill", [second]: "command", [neighbor]: "other plugin" }); + target.manifest.addTool("claude", "test", []); + target.manifest.addTool("codex", "test", []); + target.manifest.addPlugin( + "claude", + InstalledPlugin.fromJSON({ + name: "project-plugin", + source: { kind: "local", path: "/plugins/project-plugin" }, + version: "1.0.0", + strict: false, + scope: "project", + files: { [first]: hasher.hash("skill").value, [second]: hasher.hash("command").value }, + }) + ); + + const result = await remove(target, ["claude"]); + + expect(target.fs.getFile(join(PROJECT_ROOT, first))).toBeUndefined(); + expect(target.fs.getFile(join(PROJECT_ROOT, second))).toBeUndefined(); + expect(target.fs.getFile(join(PROJECT_ROOT, neighbor))).toBe("other plugin"); + expect(target.manifest.hasTool("claude")).toBe(false); + expect(target.manifest.hasTool("codex")).toBe(true); + expect(result).toStrictEqual([{ toolId: "claude", fileCount: 0, deletedFiles: [] }]); + }); + it("announces each tool it removes", async () => { const target = project({}); target.manifest.addTool("claude", "test", []); diff --git a/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts index 167230af4..9c096f9b6 100644 --- a/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts @@ -8,6 +8,7 @@ import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { UninstallUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-use-case.js"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { InputRequiredError, NoManifestError, @@ -15,14 +16,76 @@ import { } from "../../../../src/kernel/errors.js"; import type { ToolId } from "../../../../src/kernel/tool.js"; import { buildUnitDeps, initProject, installTool } from "../../../helpers/ports/build-unit-deps.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; const PROJECT_ROOT = "/test-project"; describe("uninstall", () => { + it.each(["cursor", "codex"] as const)( + "detaches only the selected %s tool's shared claims after local removal", + async (selected) => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + for (const toolId of ["cursor", "codex"] as const) + await installTool(deps, PROJECT_ROOT, toolId); + const project = deps.manifestRepo.getCurrent(); + if (project === null) throw new Error("fixture missing project"); + const record = (name: string, scope: "user" | "project") => + InstalledPlugin.fromMetadata( + name, + "1.0.0", + { kind: "local", path: "/shared" }, + false, + scope + ); + project.addPlugin("cursor", record("shared", "user")); + project.addPlugin("cursor", record("local-only", "project")); + const registration = { + binary: "codex", + marketplaces: [{ alias: "aidd", hostName: "aidd" }], + pluginRefs: ["native@aidd"], + }; + project.setNativeRegistrations("codex", registration); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + for (const name of ["shared", "local-only"]) + machine.addPlugin("cursor", record(name, "user").withDependents([PROJECT_ROOT, "/B"])); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + ...registration, + pluginClaims: [{ ref: "native@aidd", dependents: [PROJECT_ROOT, "/B"] }], + }); + await deps.userManifestRepo.save(machine); + const retainedTool = selected === "cursor" ? "codex" : "cursor"; + + await new UninstallUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.userManifestRepo + ).execute({ toolIds: [selected], projectRoot: PROJECT_ROOT }); + + expect(deps.manifestRepo.getCurrent()?.hasTool(selected)).toBe(false); + expect(deps.manifestRepo.getCurrent()?.hasTool(retainedTool)).toBe(true); + expect( + deps.userManifestRepo + .getCurrent() + ?.getPlugins("cursor") + .map((plugin) => ({ + name: plugin.name, + dependents: plugin.dependents, + })) + ).toEqual([ + { name: "shared", dependents: selected === "cursor" ? ["/B"] : [PROJECT_ROOT, "/B"] }, + { name: "local-only", dependents: [PROJECT_ROOT, "/B"] }, + ]); + expect( + deps.userManifestRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims + ).toEqual([ + { ref: "native@aidd", dependents: selected === "codex" ? ["/B"] : [PROJECT_ROOT, "/B"] }, + ]); + } + ); + it("no longer tracks removed tool files", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initProject(deps, PROJECT_ROOT); @@ -32,7 +95,6 @@ describe("uninstall", () => { await useCase.execute({ toolIds: ["claude" as ToolId], projectRoot: PROJECT_ROOT, - mcpFilter: [], }); const manifest = await deps.manifestRepo.load(); @@ -51,7 +113,7 @@ describe("uninstall", () => { const useCase = new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger); await expect( - useCase.execute({ toolIds: ["claude" as ToolId], projectRoot: PROJECT_ROOT, mcpFilter: [] }) + useCase.execute({ toolIds: ["claude" as ToolId], projectRoot: PROJECT_ROOT }) ).resolves.not.toThrow(); }); @@ -68,7 +130,6 @@ describe("uninstall", () => { await useCase.execute({ toolIds: ["claude" as ToolId], projectRoot: PROJECT_ROOT, - mcpFilter: [], }); expect(deps.fs.has(sharedFile)).toBe(true); @@ -87,7 +148,6 @@ describe("uninstall", () => { await useCase.execute({ toolIds: ["vscode" as ToolId], projectRoot: PROJECT_ROOT, - mcpFilter: [], }); expect(deps.fs.has(settingsPath)).toBe(false); @@ -105,30 +165,11 @@ describe("uninstall", () => { await useCase.execute({ toolIds: ["vscode" as ToolId], projectRoot: PROJECT_ROOT, - mcpFilter: [], }); expect(deps.fs.has(keybindingsPath)).toBe(false); }); }); - - describe("MCP removal", () => { - it("full tool removal still works without mcpFilter", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initProject(deps, PROJECT_ROOT); - await installTool(deps, PROJECT_ROOT, "claude" as ToolId); - - const useCase = new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger); - await useCase.execute({ - toolIds: ["claude" as ToolId], - projectRoot: PROJECT_ROOT, - mcpFilter: [], - }); - - const manifest = await deps.manifestRepo.load(); - expect(manifest?.getInstalledToolIds()).not.toContain("claude"); - }); - }); }); describe("uninstall — refusals", () => { @@ -140,11 +181,10 @@ describe("uninstall — refusals", () => { new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ toolIds: [], projectRoot: PROJECT_ROOT, - mcpFilter: [], }) ).rejects.toThrow( new InputRequiredError( - "At least one tool ID is required. Valid tools: claude, cursor, copilot, opencode, codex, vscode" + "At least one tool ID is required. Valid tools: claude, cursor, copilot, opencode, kilo, codex, vscode" ) ); }); @@ -156,7 +196,6 @@ describe("uninstall — refusals", () => { new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, - mcpFilter: [], }) ).rejects.toThrow(NoManifestError); }); @@ -169,45 +208,7 @@ describe("uninstall — refusals", () => { new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, - mcpFilter: [], }) ).rejects.toThrow(ToolNotInstalledError); }); }); - -describe("uninstall — an MCP filter", () => { - it("strips the named entries and leaves the tool installed", async () => { - const hasher = new DeterministicHasher(); - const servers = { github: { command: "gh" }, playwright: { command: "npx" } }; - const fs = new InMemoryFileAdapter( - { [join(PROJECT_ROOT, ".mcp.json")]: JSON.stringify({ mcpServers: servers }) }, - hasher - ); - const manifest = Manifest.create(); - manifest.addTool( - "claude", - "test", - [], - [ - { - relativePath: ".mcp.json", - sectionKey: "mcpServers", - entries: { - github: hasher.hash(JSON.stringify(servers.github)), - playwright: hasher.hash(JSON.stringify(servers.playwright)), - }, - }, - ] - ); - const repo = new InMemoryManifestRepository(manifest); - - const results = await new UninstallUseCase(fs, repo, new CapturingLogger()).execute({ - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - mcpFilter: ["github"], - }); - - expect(results).toStrictEqual([{ toolId: "claude", fileCount: 1, deletedFiles: ["github"] }]); - expect(repo.getCurrent()?.hasTool("claude")).toBe(true); - }); -}); diff --git a/cli/tests/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.unit.test.ts deleted file mode 100644 index 6a122cbf3..000000000 --- a/cli/tests/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.unit.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { UninstallMcpExclusionUseCase } from "../../../../../src/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.js"; -import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; -import type { FileHash } from "../../../../../src/kernel/file.js"; -import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; - -const PROJECT_ROOT = "/test-project"; -const MCP = ".mcp.json"; -const SECTION = "mcpServers"; -const hasher = new DeterministicHasher(); -const GITHUB = { command: "gh" }; -const PLAYWRIGHT = { command: "npx" }; -const CONTENT = JSON.stringify({ [SECTION]: { github: GITHUB, playwright: PLAYWRIGHT } }); - -function hashOf(value: unknown): FileHash { - return hasher.hash(JSON.stringify(value)); -} - -function claudeMerging( - content: string, - entries: Record, - sectionKey: string | null = SECTION -): { fs: InMemoryFileAdapter; manifest: Manifest } { - const fs = new InMemoryFileAdapter({ [join(PROJECT_ROOT, MCP)]: content }, hasher); - const manifest = Manifest.create(); - manifest.addTool("claude", "test", [], [{ relativePath: MCP, sectionKey, entries }]); - return { fs, manifest }; -} - -function twoServers() { - return claudeMerging(CONTENT, { github: hashOf(GITHUB), playwright: hashOf(PLAYWRIGHT) }); -} - -function exclude( - project: { fs: InMemoryFileAdapter; manifest: Manifest }, - mcpFilter: string[], - logger = new CapturingLogger() -) { - return new UninstallMcpExclusionUseCase(project.fs, logger).execute({ - toolId: "claude", - manifest: project.manifest, - projectRoot: PROJECT_ROOT, - mcpFilter, - }); -} - -describe("UninstallMcpExclusionUseCase", () => { - it("announces the tool it strips entries from", async () => { - const logger = new CapturingLogger(); - - await exclude(twoServers(), ["github"], logger); - - expect(logger.infoMessages).toStrictEqual(["Removing MCP entries from claude..."]); - }); - - it("removes the named entries that exist and reports exactly those", async () => { - const project = twoServers(); - - const result = await exclude(project, ["github", "absent"]); - - expect(result).toStrictEqual({ toolId: "claude", fileCount: 1, deletedFiles: ["github"] }); - expect(JSON.parse(project.fs.getFile(join(PROJECT_ROOT, MCP)) ?? "")).toStrictEqual({ - [SECTION]: { playwright: PLAYWRIGHT }, - }); - }); - - it("stops tracking the entries it removed", async () => { - const project = twoServers(); - - await exclude(project, ["github"]); - - expect(project.manifest.getMergeFiles("claude")).toStrictEqual([ - { relativePath: MCP, sectionKey: SECTION, entries: { playwright: hashOf(PLAYWRIGHT) } }, - ]); - }); - - it("records each removal so a later install leaves the entry out", async () => { - const project = twoServers(); - - await exclude(project, ["github"]); - - expect(project.manifest.getExcludedMcp("claude")).toStrictEqual([ - { configPath: MCP, entryKey: "github" }, - ]); - }); - - it("leaves a file byte-identical when none of the named entries is in it", async () => { - const project = twoServers(); - - const result = await exclude(project, ["absent"]); - - expect(result).toStrictEqual({ toolId: "claude", fileCount: 0, deletedFiles: [] }); - expect(project.fs.getFile(join(PROJECT_ROOT, MCP))).toBe(CONTENT); - }); - - it("leaves a file tracked without a section untouched even when it holds a named key", async () => { - const content = JSON.stringify({ github: GITHUB }); - const project = claudeMerging(content, { github: hashOf(GITHUB) }, null); - - const result = await exclude(project, ["github"]); - - expect(result).toStrictEqual({ toolId: "claude", fileCount: 0, deletedFiles: [] }); - expect(project.fs.getFile(join(PROJECT_ROOT, MCP))).toBe(content); - }); -}); diff --git a/cli/tests/contexts/framework/domain/manifest-round-trip.unit.test.ts b/cli/tests/contexts/framework/domain/manifest-round-trip.unit.test.ts index 3fb07bbd6..5ef74d949 100644 --- a/cli/tests/contexts/framework/domain/manifest-round-trip.unit.test.ts +++ b/cli/tests/contexts/framework/domain/manifest-round-trip.unit.test.ts @@ -28,7 +28,6 @@ describe("Manifest round-trip: every fixture rewrites byte-identical", () => { const names = fixtureNames(); expect(names).toContain("multi-tool.json"); expect(names).toContain("merge-files.json"); - expect(names).toContain("mcp-exclusions.json"); expect(names).toContain("plugins.json"); expect(names).toContain("full.json"); }); diff --git a/cli/tests/contexts/framework/domain/manifest.unit.test.ts b/cli/tests/contexts/framework/domain/manifest.unit.test.ts index 66283a081..862b39193 100644 --- a/cli/tests/contexts/framework/domain/manifest.unit.test.ts +++ b/cli/tests/contexts/framework/domain/manifest.unit.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; -import type { McpExclusion } from "../../../../src/contexts/tools/domain/mcp-exclusion.js"; import { InvalidManifestDataError, ToolNotInManifestError } from "../../../../src/kernel/errors.js"; import { FileHash, InstallationFile } from "../../../../src/kernel/file.js"; import type { MergeFileEntry } from "../../../../src/kernel/merge.js"; @@ -199,87 +198,7 @@ describe("Manifest", () => { }); }); - describe("MCP exclusion tracking", () => { - const exclusionA: McpExclusion = { configPath: ".mcp.json", entryKey: "playwright" }; - const exclusionB: McpExclusion = { configPath: ".mcp.json", entryKey: "github" }; - - it("addTool with excludedMcp stores exclusions", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusionA]); - expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([exclusionA]); - }); - - it("getExcludedMcp returns empty array for tool without exclusions", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([]); - }); - - it("addExcludedMcp appends and deduplicates", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - manifest.addExcludedMcp("claude" as ToolId, [exclusionA]); - manifest.addExcludedMcp("claude" as ToolId, [exclusionA, exclusionB]); - const result = manifest.getExcludedMcp("claude" as ToolId); - expect(result).toHaveLength(2); - expect(result).toEqual([exclusionA, exclusionB]); - }); - - it("addExcludedMcp throws for uninstalled tool", () => { - const manifest = Manifest.create(); - expect(() => manifest.addExcludedMcp("claude" as ToolId, [exclusionA])).toThrow( - /not installed/ - ); - }); - - it("removeExcludedMcp removes matching entries", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusionA, exclusionB]); - manifest.removeExcludedMcp("claude" as ToolId, [exclusionA]); - expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([exclusionB]); - }); - - it("removeExcludedMcp throws for uninstalled tool", () => { - const manifest = Manifest.create(); - expect(() => manifest.removeExcludedMcp("claude" as ToolId, [exclusionA])).toThrow( - /not installed/ - ); - }); - - it("clearExcludedMcp empties the list", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusionA, exclusionB]); - manifest.clearExcludedMcp("claude" as ToolId); - expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([]); - }); - - it("clearExcludedMcp throws for uninstalled tool", () => { - const manifest = Manifest.create(); - expect(() => manifest.clearExcludedMcp("claude" as ToolId)).toThrow(/not installed/); - }); - - it("toJSON/fromJSON round-trip preserves excludedMcp", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusionA, exclusionB]); - const restored = Manifest.fromJSON(manifest.toJSON()); - expect(restored.getExcludedMcp("claude" as ToolId)).toEqual([exclusionA, exclusionB]); - }); - - it("fromJSON handles missing excludedMcp (backward compat)", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - const json = manifest.toJSON(); - const restored = Manifest.fromJSON(json); - expect(restored.getExcludedMcp("claude" as ToolId)).toEqual([]); - }); - - it("toJSON omits excludedMcp when empty", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - const json = manifest.toJSON(); - expect(json.tools.claude).not.toHaveProperty("excludedMcp"); - }); - + describe("updateToolMergeFiles()", () => { it("updateToolMergeFiles replaces merge files without touching regular files", () => { const mergeEntry: MergeFileEntry = { relativePath: ".mcp.json", @@ -287,7 +206,7 @@ describe("Manifest", () => { entries: { playwright: makeHash("aabb") }, }; const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [mergeEntry], [exclusionA]); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [mergeEntry]); const updatedMerge: MergeFileEntry = { relativePath: ".mcp.json", sectionKey: "mcpServers", @@ -296,7 +215,6 @@ describe("Manifest", () => { manifest.updateToolMergeFiles("claude" as ToolId, [updatedMerge]); expect(manifest.getMergeFiles("claude" as ToolId)).toEqual([updatedMerge]); expect(manifest.getToolFiles("claude" as ToolId)).toHaveLength(2); - expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([exclusionA]); }); it("updateToolMergeFiles throws for uninstalled tool", () => { @@ -494,10 +412,6 @@ describe("Manifest", () => { expect(Manifest.create().getToolFiles("claude" as ToolId)).toStrictEqual([]); }); - it("has no MCP exclusions", () => { - expect(Manifest.create().getExcludedMcp("claude" as ToolId)).toStrictEqual([]); - }); - it("has no native registrations", () => { expect(Manifest.create().getNativeRegistrations("claude" as ToolId)).toBeUndefined(); }); @@ -585,47 +499,31 @@ describe("Manifest", () => { }); }); - describe("clearExcludedMcp()", () => { - it("keeps the tool's version and files", () => { - const manifest = Manifest.create(); - manifest.addTool( - "claude" as ToolId, - "3.0.0", - claudeFiles, - [], - [{ configPath: ".mcp.json", entryKey: "playwright" }] - ); - - manifest.clearExcludedMcp("claude" as ToolId); - - expect(manifest.getToolVersion("claude" as ToolId)).toBe("3.0.0"); - expect(manifest.getToolFiles("claude" as ToolId).map((f) => f.relativePath)).toStrictEqual([ - ".claude/agents/code-reviewer.md", - ".claude/rules/naming.md", - ]); - }); - }); - - describe("updateToolMergeFiles()", () => { - const exclusion: McpExclusion = { configPath: ".mcp.json", entryKey: "playwright" }; - - it("replaces the exclusions when handed some", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusion]); - const replacement: McpExclusion = { configPath: ".mcp.json", entryKey: "github" }; - - manifest.updateToolMergeFiles("claude" as ToolId, [], [replacement]); + describe("a manifest written while the CLI still recorded MCP exclusions", () => { + const written = { + version: 8, + tools: { + claude: { + toolId: "claude", + version: "1.0.0", + files: [], + mergeFiles: [], + excludedMcp: [{ configPath: ".claude/settings.json", entryKey: "old-server" }], + }, + }, + }; - expect(manifest.getExcludedMcp("claude" as ToolId)).toStrictEqual([replacement]); + it("still loads, keeping the tool it records", () => { + expect(Manifest.fromJSON(written).getToolVersion("claude" as ToolId)).toBe("1.0.0"); }); - it("keeps the exclusions when handed none", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusion]); - - manifest.updateToolMergeFiles("claude" as ToolId, []); - - expect(manifest.getExcludedMcp("claude" as ToolId)).toStrictEqual([exclusion]); + it("drops the exclusions on its next write", () => { + expect(Manifest.fromJSON(written).toJSON().tools.claude).toStrictEqual({ + toolId: "claude", + version: "1.0.0", + files: [], + mergeFiles: [], + }); }); }); diff --git a/cli/tests/contexts/framework/domain/manifest/mcp-exclusions.unit.test.ts b/cli/tests/contexts/framework/domain/manifest/mcp-exclusions.unit.test.ts deleted file mode 100644 index d4f33fd24..000000000 --- a/cli/tests/contexts/framework/domain/manifest/mcp-exclusions.unit.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - addExclusions, - removeExclusions, -} from "../../../../../src/contexts/framework/domain/manifest/mcp-exclusions.js"; -import type { McpExclusion } from "../../../../../src/contexts/tools/domain/mcp-exclusion.js"; - -const playwright: McpExclusion = { configPath: ".mcp.json", entryKey: "playwright" }; -const github: McpExclusion = { configPath: ".mcp.json", entryKey: "github" }; -const context7: McpExclusion = { configPath: ".mcp.json", entryKey: "context7" }; - -describe("MCP exclusions recorded for a tool", () => { - it("keeps the exclusions already recorded ahead of the ones added", () => { - expect(addExclusions([playwright], [github])).toStrictEqual([playwright, github]); - }); - - it("removes exactly the exclusions named, keeping the rest", () => { - expect(removeExclusions([playwright, github, context7], [playwright, context7])).toStrictEqual([ - github, - ]); - }); -}); diff --git a/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts b/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts index 3fe84e62a..f291a228b 100644 --- a/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts +++ b/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { type NativeRegistrations, + type NativeRegistrationsData, parseNativeRegistrations, toNativeRegistrationsData, } from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; @@ -12,6 +13,30 @@ const registrations: NativeRegistrations = { }; describe("native registrations — what a tool's own CLI was asked to register", () => { + it("round-trips the exact effective Codex source while legacy entries remain unproven", () => { + const proven: NativeRegistrations = { + binary: "codex", + marketplaces: [ + { + alias: "local", + hostName: "declared", + provenance: { + kind: "effective-list", + root: "/home/.codex/plugins/marketplaces/declared", + sourceType: "local", + source: "/project-a/.aidd/cache/built", + }, + }, + ], + pluginRefs: [], + }; + + expect(parseNativeRegistrations(toNativeRegistrationsData(proven))).toStrictEqual(proven); + expect(parseNativeRegistrations(toNativeRegistrationsData(registrations))).toStrictEqual( + registrations + ); + }); + describe("serialized", () => { it("carries the binary, every marketplace pair and every plugin ref", () => { expect(toNativeRegistrationsData(registrations)).toStrictEqual({ @@ -23,6 +48,67 @@ describe("native registrations — what a tool's own CLI was asked to register", }); describe("parsed", () => { + it("keeps a registry source without requiring an effective-list root", () => { + const data: NativeRegistrationsData = { + binary: "claude", + marketplaces: [ + { + alias: "local", + hostName: "declared", + provenance: { kind: "registry", source: "/built" }, + }, + ], + pluginRefs: [], + }; + + expect(parseNativeRegistrations(data)).toStrictEqual(data); + }); + + it.each([ + ["null", null], + ["scalar", "registry"], + ["unknown kind", { kind: "unknown", source: "/built" }], + [ + "unknown kind with effective-list fields", + { kind: "unknown", source: "/built", root: "/host", sourceType: "local" }, + ], + ["missing source", { kind: "registry" }], + ["empty source", { kind: "registry", source: "" }], + ["non-string source", { kind: "registry", source: 1 }], + ["missing root", { kind: "effective-list", source: "/built", sourceType: "local" }], + ["empty root", { kind: "effective-list", source: "/built", sourceType: "local", root: "" }], + [ + "non-string root", + { kind: "effective-list", source: "/built", sourceType: "local", root: 1 }, + ], + ["missing source type", { kind: "effective-list", source: "/built", root: "/host" }], + [ + "empty source type", + { kind: "effective-list", source: "/built", root: "/host", sourceType: "" }, + ], + [ + "non-string source type", + { kind: "effective-list", source: "/built", root: "/host", sourceType: 1 }, + ], + ])("leaves %s provenance unproven without dropping the registration", (_name, provenance) => { + // A persisted manifest is JSON: its provenance may not satisfy the declared type. + const data = { + binary: "codex", + marketplaces: [{ alias: "local", hostName: "declared", provenance }], + pluginRefs: ["plugin@declared"], + } as NativeRegistrationsData; + + expect(parseNativeRegistrations(data)).toStrictEqual({ + binary: "codex", + marketplaces: [{ alias: "local", hostName: "declared" }], + pluginRefs: ["plugin@declared"], + }); + }); + + it("keeps an absent registration absent", () => { + expect(parseNativeRegistrations(undefined)).toBeUndefined(); + }); + it("reads back the binary, every marketplace pair and every plugin ref", () => { expect( parseNativeRegistrations({ @@ -37,4 +123,32 @@ describe("native registrations — what a tool's own CLI was asked to register", }); }); }); + + it.each(["serialize", "parse"] as const)( + "%s produces an independent ownership snapshot", + (action) => { + const dependents = ["/project-a", "/project-b"]; + const provenance = { kind: "registry" as const, source: "/built" }; + const marketplace = { alias: "local", hostName: "declared", provenance }; + const pluginClaims = [{ ref: "plugin@declared", dependents }]; + const input: NativeRegistrationsData = { + binary: "claude", + marketplaces: [marketplace], + pluginRefs: ["plugin@declared", "other@declared"], + pluginClaims, + }; + const expected = structuredClone(input); + const snapshot = + action === "serialize" ? toNativeRegistrationsData(input) : parseNativeRegistrations(input); + + provenance.source = "/foreign"; + marketplace.alias = "changed"; + input.marketplaces.push({ alias: "extra", hostName: "extra" }); + input.pluginRefs.push("extra@declared"); + pluginClaims[0] = { ref: "changed@declared", dependents: [] }; + dependents.push("/project-c"); + + expect(snapshot).toStrictEqual(expected); + } + ); }); diff --git a/cli/tests/contexts/framework/domain/manifest/tool-entry.unit.test.ts b/cli/tests/contexts/framework/domain/manifest/tool-entry.unit.test.ts index c0fe68116..55bcacd7a 100644 --- a/cli/tests/contexts/framework/domain/manifest/tool-entry.unit.test.ts +++ b/cli/tests/contexts/framework/domain/manifest/tool-entry.unit.test.ts @@ -34,7 +34,6 @@ const makeEntry = (plugins: InstalledPlugin[]): ToolEntry => }), ], mergeFiles: [{ relativePath: ".mcp.json", sectionKey: "mcpServers", entries: {} }], - excludedMcp: [], existingPlugins: plugins, }); diff --git a/cli/tests/contexts/framework/domain/plugin-asset-translation.unit.test.ts b/cli/tests/contexts/framework/domain/plugin-asset-translation.unit.test.ts index e889af5c2..a49f34034 100644 --- a/cli/tests/contexts/framework/domain/plugin-asset-translation.unit.test.ts +++ b/cli/tests/contexts/framework/domain/plugin-asset-translation.unit.test.ts @@ -6,11 +6,13 @@ import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { claude } from "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import { codex } from "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import { copilot } from "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { cursor } from "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { kilo } from "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { opencode } from "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { getAiToolConfig } from "../../../../src/contexts/tools/domain/registry.js"; import { PluginContentTranslator } from "../../../../src/contexts/translate/domain/content-translator.js"; @@ -77,7 +79,7 @@ describe("installing the plugin carries a skill's own script, on every tool", () }); } - for (const tool of [claude, codex, copilot, cursor, opencode]) { + for (const tool of [claude, codex, copilot, cursor, kilo, opencode]) { it(`${tool.toolId} installs it byte for byte`, () => { const installed = translator .translate(distributionOf(), tool) diff --git a/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts index f5899af98..2e55eb636 100644 --- a/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts +++ b/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts @@ -13,6 +13,26 @@ import { MalformedPluginScopeError, } from "../../../../../src/kernel/errors.js"; +it("round-trips exact Cursor project hook provenance through the plugin manifest", () => { + const data = makePluginData({ + projectHooks: { + entries: [ + { + event: "preToolUse", + command: "node ./.cursor/hooks/my-plugin/pre.js", + digest: "a".repeat(32), + }, + ], + scripts: { ".cursor/hooks/my-plugin/pre.js": "b".repeat(32) }, + }, + }); + const installed = InstalledPlugin.fromJSON(data); + expect(installed.projectHooks?.scripts.get(".cursor/hooks/my-plugin/pre.js")).toBe( + "b".repeat(32) + ); + expect(InstalledPlugin.fromJSON(installed.toJSON()).toJSON()).toEqual(data); +}); + const makeDistribution = (strict?: boolean): PluginDistribution => new PluginDistribution({ manifest: { name: "my-plugin", version: "1.0.0", ...(strict === undefined ? {} : { strict }) }, diff --git a/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts index c498d09c4..25e1c1d10 100644 --- a/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts +++ b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts @@ -1,10 +1,12 @@ import { existsSync } from "node:fs"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { setTimeout as pause } from "node:timers/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { UserManifestRepositoryAdapter } from "../../../../src/contexts/framework/infrastructure/user-manifest-repository-adapter.js"; +import { atomicWriteFile } from "../../../../src/runtime/filesystem/atomic-write.js"; describe("UserManifestRepositoryAdapter", () => { let userConfigDir: string; @@ -12,7 +14,7 @@ describe("UserManifestRepositoryAdapter", () => { beforeEach(async () => { userConfigDir = await mkdtemp(join(tmpdir(), "aidd-user-manifest-repo-")); - adapter = new UserManifestRepositoryAdapter(() => userConfigDir); + adapter = new UserManifestRepositoryAdapter(() => userConfigDir, atomicWriteFile); }); afterEach(async () => { @@ -76,6 +78,68 @@ describe("UserManifestRepositoryAdapter", () => { }); }); + describe("exclusive machine mutations", () => { + it("fails closed at the bounded deadline without stealing another process's lock", async () => { + const lock = `${adapter.path}.lock`; + await mkdir(lock); + const now = vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(30_000); + await expect(adapter.withExclusiveAccess(async () => {})).rejects.toThrow( + /User manifest is busy.*\.lock/ + ); + now.mockRestore(); + expect(existsSync(lock)).toBe(true); + }); + + it("serializes two claims so the second reads the first after the lock releases", async () => { + const second = new UserManifestRepositoryAdapter(() => userConfigDir, atomicWriteFile); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const firstMutation = adapter.withExclusiveAccess(async () => { + const manifest = (await adapter.load()) ?? Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + await adapter.save(manifest); + await held; + }); + await pause(20); + expect(existsSync(`${adapter.path}.lock`)).toBe(true); + let secondEntered = false; + const secondMutation = second.withExclusiveAccess(async () => { + secondEntered = true; + const manifest = (await second.load()) ?? Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + await second.save(manifest); + }); + await pause(150); + expect(secondEntered).toBe(false); + release(); + await Promise.all([firstMutation, secondMutation]); + expect((await adapter.load())?.getInstalledToolIds()).toEqual(["cursor", "codex"]); + expect(existsSync(`${adapter.path}.lock`)).toBe(false); + }); + + it("keeps the old JSON and releases the lock when the atomic writer fails", async () => { + const baseline = Manifest.create(); + baseline.addTool("cursor", "1.0.0", []); + await adapter.save(baseline); + const failing = new UserManifestRepositoryAdapter( + () => userConfigDir, + async () => { + throw new Error("write failed before rename"); + } + ); + const replacement = Manifest.create(); + replacement.addTool("codex", "1.0.0", []); + await expect(failing.withExclusiveAccess(() => failing.save(replacement))).rejects.toThrow( + "write failed before rename" + ); + expect((await adapter.load())?.getInstalledToolIds()).toEqual(["cursor"]); + expect(existsSync(`${adapter.path}.lock`)).toBe(false); + await expect(adapter.withExclusiveAccess(async () => {})).resolves.toBeUndefined(); + }); + }); + describe("delete()", () => { it("deletes manifest.json from disk", async () => { const manifest = Manifest.create(); @@ -108,7 +172,7 @@ describe("UserManifestRepositoryAdapter", () => { describe("manifest persistence", () => { it("creates the user config dir if it does not exist yet", async () => { const freshDir = join(userConfigDir, "not-yet-created"); - const freshAdapter = new UserManifestRepositoryAdapter(() => freshDir); + const freshAdapter = new UserManifestRepositoryAdapter(() => freshDir, atomicWriteFile); await freshAdapter.save(Manifest.create()); diff --git a/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts index 5b4c177d3..0b19c9176 100644 --- a/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts @@ -4,6 +4,7 @@ import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../../../../src/contexts/framework/domain/ports/manifest-repository.js"; @@ -34,7 +35,10 @@ class StubHookTrustReader implements HookTrustReader { configPath: "/home/.codex/config.toml", }; + reads = 0; + async read(): Promise { + this.reads += 1; return this.trust; } } @@ -94,6 +98,7 @@ function buildUseCase(options: { hookTrustReader?: StubHookTrustReader; manifestRepo?: ManifestRepository; hostRegistries?: ReadonlyMap; + sink?: InMemoryTelemetrySink; }) { const evidence = options.evidence ?? new StubEvidenceReader(); const journalReader = new InMemoryRunJournalReader(); @@ -101,6 +106,7 @@ function buildUseCase(options: { journalReader.set(journal.session?.vendor_id ?? `no-session-${index}`, journal); } const hookTrustReader = options.hookTrustReader ?? new StubHookTrustReader(); + const sink = options.sink ?? new InMemoryTelemetrySink(); const useCase = new DiagnoseTelemetryUseCase( evidence, versionControl(options.isRepository ?? true), @@ -108,7 +114,7 @@ function buildUseCase(options: { options.readers ?? new Map(), hookTrustReader, new InMemoryPersonIdentityStore(), - new InMemoryTelemetrySink(), + sink, new FakeCurrentVersion("9.9.9-check"), installedPluginsFromManifest( options.manifestRepo ?? { @@ -120,7 +126,7 @@ function buildUseCase(options: { ), options.hostRegistries ?? new Map() ); - return { useCase, evidence, hookTrustReader, journalReader }; + return { useCase, evidence, hookTrustReader, journalReader, sink }; } function runOptions(env: NodeJS.ProcessEnv = {}) { @@ -546,3 +552,469 @@ describe("DiagnoseTelemetryUseCase — what the host will actually load", () => expect(registration.entries).toEqual([]); }); }); + +class RecordingSessionCostReader implements SessionCostReader { + readonly asked: string[] = []; + + constructor(private readonly result: LocalCostReadResult) {} + + async read(sessionId: string): Promise { + this.asked.push(sessionId); + return this.result; + } +} + +function journalOf(session: RunJournal["session"], lines: Partial = {}): RunJournal { + return { boundaries: [], filesWritten: [], taskDeclarations: [], session, ...lines }; +} + +function claimsOf(result: Awaited>) { + if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); + return result; +} + +function useCaseOverIdentity(store: InMemoryPersonIdentityStore): DiagnoseTelemetryUseCase { + return new DiagnoseTelemetryUseCase( + new StubEvidenceReader(), + versionControl(true), + new InMemoryRunJournalReader(), + new Map(), + new StubHookTrustReader(), + store, + new InMemoryTelemetrySink(), + new FakeCurrentVersion("9.9.9-check"), + installedPluginsFromManifest(new InMemoryManifestRepository(Manifest.create())), + new Map() + ); +} + +const HERE = "git@github.com:acme/widgets.git"; + +async function sinkHolding( + vendorId: string, + projectRemote: string +): Promise { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord( + { + sink_schema_version: 1, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: vendorId, + vendor_field: "session_id", + step_attribution: "unattributed", + project_id: projectRemote, + project_field: "project_remote", + }, + new Date("2026-08-20T09:01:00Z") + ); + return sink; +} + +function journalledHere(vendorId: string): RunJournal { + const session = sessionStart(vendorId); + return journalOf(session && { ...session, project_remote: HERE }); +} + +describe("DiagnoseTelemetryUseCase — an anchor inherited from another project", () => { + it("names the project the stored figures put the anchored session under", async () => { + const { useCase } = buildUseCase({ + journals: [journalledHere("s-old")], + sink: await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"), + }); + + const result = claimsOf( + await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-elsewhere" })) + ); + + const hookFired = result.claims.find((claim) => claim.claim === "hook-fired"); + expect(hookFired?.verdict).toBe("unknown"); + expect(hookFired?.detail).toContain("git@github.com:acme/other.git"); + }); + + it("still fails an anchor the stored figures put in this very project", async () => { + const { useCase } = buildUseCase({ + journals: [journalledHere("s-old")], + sink: await sinkHolding("s-mine", HERE), + }); + + const result = claimsOf( + await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-mine" })) + ); + + expect(result.claims.find((claim) => claim.claim === "hook-fired")?.reason).toBe( + "session-left-no-run-file" + ); + }); + + it("asks the sink nothing about a session this project journalled itself", async () => { + const sink = await sinkHolding("s-1", "git@github.com:acme/other.git"); + const { useCase } = buildUseCase({ + journals: [journalOf(undefined), journalledHere("s-old"), journalledHere("s-1")], + sink, + }); + + const result = claimsOf(await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-1" }))); + + expect(result.claims.find((claim) => claim.claim === "hook-fired")?.verdict).toBe("ok"); + expect(sink.vendorsRead).toStrictEqual([]); + }); + + it("asks the sink nothing when no anchor names a session at all", async () => { + const sink = await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"); + const { useCase } = buildUseCase({ journals: [journalledHere("s-old")], sink }); + + claimsOf(await useCase.execute(runOptions())); + + expect(sink.vendorsRead).toStrictEqual([]); + }); + + it("asks the sink nothing when no run file here names a project to compare against", async () => { + const sink = await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"); + const { useCase } = buildUseCase({ journals: [journalOf(sessionStart("s-old"))], sink }); + + const result = claimsOf( + await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-elsewhere" })) + ); + + expect(result.claims.find((claim) => claim.claim === "hook-fired")?.reason).toBe( + "session-left-no-run-file" + ); + expect(sink.vendorsRead).toStrictEqual([]); + }); + + it("asks the sink about the anchor alone", async () => { + const sink = await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"); + const { useCase } = buildUseCase({ journals: [journalledHere("s-old")], sink }); + + claimsOf(await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-elsewhere" }))); + + expect(sink.vendorsRead).toStrictEqual(["s-elsewhere"]); + }); +}); + +describe("DiagnoseTelemetryUseCase — the setup it prints", () => { + it("names the sink's own root as where records land", async () => { + const { useCase } = buildUseCase({}); + + const result = await useCase.execute(runOptions()); + + expect(result.setup.recordsLocation).toStrictEqual({ path: "/fake/telemetry" }); + }); + + it("reports nobody chose an identity as unattached and readable, at the store's own path", async () => { + const { useCase } = buildUseCase({}); + + const result = await useCase.execute(runOptions()); + + expect(result.setup.identity).toStrictEqual({ + attached: false, + path: "/fake/home/.config/aidd/identity.json", + readable: true, + }); + }); + + it("reports a chosen identity as attached", async () => { + const useCase = useCaseOverIdentity( + new InMemoryPersonIdentityStore({ personId: "person-1", origin: "minted", alsoMe: [] }) + ); + + const result = await useCase.execute(runOptions()); + + expect(result.setup.identity).toStrictEqual({ + attached: true, + path: "/fake/home/.config/aidd/identity.json", + readable: true, + }); + }); + + it("reports a damaged identity file as unreadable and unattached, never crashing", async () => { + const store = new InMemoryPersonIdentityStore(null); + store.throwOnRead = new Error("identity.json is a directory"); + + const result = await useCaseOverIdentity(store).execute(runOptions()); + + expect(result.setup.identity).toStrictEqual({ + attached: false, + path: "/fake/home/.config/aidd/identity.json", + readable: false, + }); + }); + + it("says exactly why a non-repository is gated, blaming no hook", async () => { + const { useCase } = buildUseCase({ isRepository: false }); + + const result = await useCase.execute(runOptions()); + + expect(result.gate).toBe( + "not a git repository — the hook has nowhere to write here, not a hook that failed to fire" + ); + }); + + it("names the uncovered tools exactly, each with its declaration's own reason", async () => { + const { useCase } = buildUseCase({}); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.uncovered).toStrictEqual([ + { tool: "cursor", reason: "It writes no token count in any file it produces." }, + { + tool: "kilo", + reason: "Kilo OpenTelemetry is experimental and not yet supported by AIDD.", + }, + ]); + }); +}); + +describe("DiagnoseTelemetryUseCase — reading the host registries", () => { + it("asks no registry for a tool the manifest records no plugin for", async () => { + let codexReads = 0; + const codexRegistry: HostPluginRegistryReader = { + read: async () => { + codexReads += 1; + return { location: "/home/dev/.codex/config.toml", refs: new Map() }; + }, + }; + const registries = new Map(registryCarrying([REF])); + registries.set("codex", codexRegistry); + const { useCase } = buildUseCase({ + manifestRepo: manifestWithClaudePlugin("aidd-framework"), + hostRegistries: registries, + }); + + const result = await useCase.execute(runOptions()); + + expect(result.setup.hostRegistration.entries.map((entry) => entry.tool)).toStrictEqual([ + "claude", + ]); + expect(codexReads).toBe(0); + }); + + it("answers unanswerable for a host that keeps a registry nothing here reads", async () => { + const { useCase } = buildUseCase({ + manifestRepo: manifestWithClaudePlugin("aidd-framework"), + hostRegistries: new Map(), + }); + + const result = await useCase.execute(runOptions()); + + expect(result.setup.hostRegistration).toStrictEqual({ + entries: [ + { + tool: "claude", + plugin: "aidd-telemetry", + ref: REF, + answer: "unanswerable", + detail: "claude keeps a plugin registry, and nothing here has established its shape", + }, + ], + }); + }); + + it("answers unanswerable for a tool that declares no native activation at all", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "aidd-telemetry", + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + "project", + "aidd-framework" + ) + ); + const { useCase } = buildUseCase({ + manifestRepo: new InMemoryManifestRepository(manifest), + hostRegistries: new Map(), + }); + + const result = await useCase.execute(runOptions()); + + expect(result.setup.hostRegistration).toStrictEqual({ + entries: [ + { + tool: "cursor", + plugin: "aidd-telemetry", + ref: REF, + answer: "unanswerable", + detail: "cursor declares no plugin registry to read", + }, + ], + }); + }); +}); + +describe("DiagnoseTelemetryUseCase — what each journal contributes to the claims", () => { + it("survives a journal whose session_start line is torn away, still naming the version another stamped", async () => { + const { useCase } = buildUseCase({ + journals: [ + journalOf(undefined), + journalOf({ + type: "session_start", + at: "2026-08-20T09:00:00Z", + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + tool: "claude-code", + vendor_id: "s-1", + plugin_version: "0.3.0", + }), + ], + }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.setup.versions.plugin).toStrictEqual({ kind: "recorded", version: "0.3.0" }); + }); + + it("asks each covered tool about the sessions the journal names, and about no other", async () => { + const claudeReader = new RecordingSessionCostReader({ records: [], sessionFound: true }); + const { useCase } = buildUseCase({ + journals: [journalOf(undefined), journalOf(sessionStart("s-1"))], + readers: new Map([["claude", claudeReader]]), + }); + + await useCase.execute(runOptions()); + + expect(claudeReader.asked).toStrictEqual(["s-1"]); + }); + + it("summarises the read per covered tool, in the order the tools are declared", async () => { + const claudeReader = new StubSessionCostReader({ records: [], sessionFound: true }); + const { useCase } = buildUseCase({ + journals: [journalOf(sessionStart("s-1"))], + readers: new Map([["claude", claudeReader]]), + }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[2]).toStrictEqual({ + claim: "tool-files-readable", + verdict: "ok", + reason: "session-found", + detail: + "claude: 1 of 1 session(s) read; copilot: 0 of 1 session(s) read; " + + "opencode: 0 of 1 session(s) read; codex: 0 of 1 session(s) read", + }); + }); + + it("says a journal carrying only session_start closed no turn", async () => { + const { useCase } = buildUseCase({ journals: [journalOf(sessionStart("s-1"))] }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[1]).toStrictEqual({ + claim: "session-journalled", + verdict: "fail", + reason: "only-session-start", + detail: "1 run file(s), all carrying only session_start — nothing closed the turn", + }); + }); + + it("says a journal carrying any boundary closed its turn", async () => { + const { useCase } = buildUseCase({ + journals: [ + journalOf(sessionStart("s-1"), { + boundaries: [{ type: "turn_end", at: "2026-08-20T09:30:00Z" }], + }), + ], + }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[1]).toStrictEqual({ + claim: "session-journalled", + verdict: "ok", + reason: "turn-closed", + detail: "1 of 1 run file(s) carry more than session_start", + }); + }); + + it("names the runs directory in the claim that found no run file", async () => { + const { useCase } = buildUseCase({}); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[0]).toStrictEqual({ + claim: "hook-fired", + verdict: "fail", + reason: "recorder-declared-nowhere", + detail: + "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + + "recorder is declared nowhere this build checks", + }); + }); + + it("never asks Codex's hook trust for a session another tool anchors", async () => { + const hookTrustReader = new StubHookTrustReader(); + const { useCase } = buildUseCase({ hookTrustReader }); + + await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-1" })); + + expect(hookTrustReader.reads).toBe(0); + }); + + it("asks Codex's hook trust once for a Codex-anchored session", async () => { + const hookTrustReader = new StubHookTrustReader(); + const { useCase } = buildUseCase({ hookTrustReader }); + + await useCase.execute(runOptions({ CODEX_THREAD_ID: "codex-1" })); + + expect(hookTrustReader.reads).toBe(1); + }); + + it("has no join material when no interval opened and no record states a step", async () => { + const claudeReader = new StubSessionCostReader({ records: [candidate()], sessionFound: true }); + const { useCase } = buildUseCase({ + journals: [journalOf(sessionStart("s-1"))], + readers: new Map([["claude", claudeReader]]), + }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[3]).toStrictEqual({ + claim: "records-join", + verdict: "unknown", + reason: "no-join-material", + detail: "no step interval and no tool-stated step — see session journalled", + }); + }); + + it("joins a record whose tool stated its step, with no interval to judge it against", async () => { + const claudeReader = new StubSessionCostReader({ + records: [candidate({ step: "aidd-dev:01-plan" })], + sessionFound: true, + }); + const { useCase } = buildUseCase({ + journals: [journalOf(sessionStart("s-1"))], + readers: new Map([["claude", claudeReader]]), + }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[3]).toStrictEqual({ + claim: "records-join", + verdict: "ok", + reason: "records-joined", + detail: "1 of 1 record(s) joined a step, 0 unattributed", + }); + }); + + it("reads no record at all from a reader that threw", async () => { + const brokenReader = new StubSessionCostReader(undefined, "ENOENT: no such file"); + const { useCase } = buildUseCase({ + journals: [journalOf(sessionStart("s-1"))], + readers: new Map([["claude", brokenReader]]), + }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[3]).toStrictEqual({ + claim: "records-join", + verdict: "unknown", + reason: "no-record-to-join", + detail: "no record read to join", + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts index 4c0894e50..5fa6f50b2 100644 --- a/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts @@ -449,3 +449,88 @@ describe("what the errors tell a person to run", () => { } }); }); + +class AddCountingStore extends InMemoryPersonIdentityStore { + addAlsoMeCalls = 0; + + override async addAlsoMe(identity: string) { + this.addAlsoMeCalls += 1; + return super.addAlsoMe(identity); + } +} + +describe("PersonIdentityUseCase — the exact shape of what it answers", () => { + it("names the verb in the refusal of an empty identifier to use", async () => { + await expect( + useCase(new InMemoryPersonIdentityStore(null)).use({ identifier: " " }) + ).rejects.toThrow("`aidd telemetry identity use` needs a non-empty value."); + }); + + it("names the verb in the refusal of an empty identifier to link", async () => { + const store = new InMemoryPersonIdentityStore({ + personId: "person-a", + origin: "minted", + alsoMe: [], + }); + + await expect(useCase(store).link(" ")).rejects.toThrow( + "`aidd telemetry identity link` needs a non-empty value." + ); + }); + + it("answers a minted identifier with no replaced identifier and no display name set", async () => { + const store = new InMemoryPersonIdentityStore(null, "fresh-id"); + + const result = await useCase(store).use({}); + + expect(result).toStrictEqual({ + filePath: "/fake/home/.config/aidd/identity.json", + identity: { personId: "fresh-id", origin: "minted", alsoMe: [] }, + outcome: "minted", + }); + }); + + it("writes no display name key at all when none was asked for", async () => { + const store = new InMemoryPersonIdentityStore(null, "fresh-id"); + + await useCase(store).use({}); + + expect(await store.read()).toStrictEqual({ + personId: "fresh-id", + origin: "minted", + alsoMe: [], + }); + }); + + it("writes onto alsoMe exactly once for a new identifier, and never for one already listed", async () => { + const store = new AddCountingStore({ + personId: "person-a", + origin: "minted", + alsoMe: ["machine-2"], + }); + const uc = useCase(store); + + await uc.link("machine-2"); + await uc.link("person-a"); + await uc.link("machine-3"); + + expect(store.addAlsoMeCalls).toBe(1); + }); + + it("answers a clean withdrawal as not discarding anything damaged", async () => { + const store = new InMemoryPersonIdentityStore({ + personId: "person-1", + origin: "minted", + alsoMe: ["a-second-machine"], + }); + + const result = await useCase(store).off(); + + expect(result).toStrictEqual({ + filePath: "/fake/home/.config/aidd/identity.json", + removed: true, + discardedDamaged: false, + addedIdentifiersRemoved: 1, + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/read-local-cost-sweep.unit.test.ts b/cli/tests/contexts/telemetry/application/read-local-cost-sweep.unit.test.ts new file mode 100644 index 000000000..680b67ca4 --- /dev/null +++ b/cli/tests/contexts/telemetry/application/read-local-cost-sweep.unit.test.ts @@ -0,0 +1,526 @@ +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import { + type LocalCostToolReport, + ReadLocalCostUseCase, +} from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; +import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; +import type { + LocalCostCandidateRecord, + SessionCostReader, +} from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import type { AiToolId } from "../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { NULL_PERSON_IDENTITY_READER } from "../../../helpers/ports/in-memory-person-identity-reader.js"; +import { + InMemoryRunJournalReader, + NULL_RUN_JOURNAL_READER, +} from "../../../helpers/ports/in-memory-run-journal-reader.js"; +import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; +import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; + +const PROJECT_ROOT = "/repo"; +const SESSION_ID = "s-1"; +const TURN_ID = "req_1"; +const AT = new Date("2026-08-20T12:00:00Z"); +const EVIDENCE_READER = new StubTelemetryEvidenceReader(); + +const CANDIDATE: LocalCostCandidateRecord = { + kind: "request", + vendor_id: SESSION_ID, + vendor_field: "sessionId", + turn_id: TURN_ID, + turn_field: "requestId", + model: "claude-sonnet-5", + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 30, + cache_creation_tokens: 40, +}; + +const STORED: TelemetrySinkRecord = { + ...CANDIDATE, + sink_schema_version: 2, + provenance: "local-read", + tool: "claude", + step_attribution: "unattributed", +}; + +const NOT_ASKED = { + status: "not-asked", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 0, +} as const; + +function notAsked(tool: AiToolId): LocalCostToolReport { + return { tool, ...NOT_ASKED }; +} + +const CURSOR_NOT_COVERED: LocalCostToolReport = { + tool: "cursor", + status: "not-covered", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 0, + reason: "It writes no token count in any file it produces.", +}; + +const KILO_NOT_COVERED: LocalCostToolReport = { + tool: "kilo", + status: "not-covered", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 0, + reason: "Kilo OpenTelemetry is experimental and not yet supported by AIDD.", +}; + +function sessionJournal(vendorId: string, host = "claude-code"): RunJournal { + return { + boundaries: [], + filesWritten: [], + taskDeclarations: [], + session: { + type: "session_start", + at: "2026-08-20T09:00:00Z", + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + tool: host, + vendor_id: vendorId, + }, + }; +} + +function journalNaming(...vendorIds: readonly string[]): InMemoryRunJournalReader { + const reader = new InMemoryRunJournalReader(); + for (const vendorId of vendorIds) reader.set(vendorId, sessionJournal(vendorId)); + return reader; +} + +type Answer = readonly LocalCostCandidateRecord[] | "absent" | Error; + +function readerAnswering(answers: ReadonlyMap): SessionCostReader { + return { + read: async (sessionId: string) => { + const answer = answers.get(sessionId) ?? "absent"; + if (answer instanceof Error) throw answer; + if (answer === "absent") return { records: [], sessionFound: false }; + return { records: answer, sessionFound: true }; + }, + }; +} + +function claudeOnly(reader: SessionCostReader): ReadonlyMap { + return new Map([["claude", reader]]); +} + +async function sweep( + readers: ReadonlyMap, + journals: InMemoryRunJournalReader | typeof NULL_RUN_JOURNAL_READER, + sink = new InMemoryTelemetrySink() +) { + const useCase = new ReadLocalCostUseCase( + sink, + readers, + journals, + NULL_PERSON_IDENTITY_READER, + EVIDENCE_READER + ); + return useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT }); +} + +async function readOne( + sink: InMemoryTelemetrySink, + candidates: readonly LocalCostCandidateRecord[] +): Promise { + const result = await new ReadLocalCostUseCase( + sink, + claudeOnly(readerAnswering(new Map([[SESSION_ID, candidates]]))), + NULL_RUN_JOURNAL_READER, + NULL_PERSON_IDENTITY_READER, + EVIDENCE_READER + ).execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT, sessionId: SESSION_ID }); + const claude = result.toolReports.find((report) => report.tool === "claude"); + if (claude === undefined) throw new Error("no claude report"); + return claude.recordsStored; +} + +function claudeReport(reports: readonly LocalCostToolReport[]): LocalCostToolReport { + const claude = reports.find((report) => report.tool === "claude"); + if (claude === undefined) throw new Error("no claude report"); + return claude; +} + +describe("which sessions a sweep reads", () => { + it("names every session the journal anchors and skips a run file that anchors none", async () => { + const journals = journalNaming("s-a"); + journals.set("torn", { boundaries: [], filesWritten: [], taskDeclarations: [] }); + journals.set("s-b", sessionJournal("s-b")); + + const result = await sweep(claudeOnly(readerAnswering(new Map())), journals); + + expect(result.sessions.map((session) => session.sessionId)).toStrictEqual(["s-a", "s-b"]); + }); + + it("answers not-asked for every tool, and nothing more, when the journal names no session", async () => { + const result = await sweep(claudeOnly(readerAnswering(new Map())), NULL_RUN_JOURNAL_READER); + + expect(result).toStrictEqual({ + sessions: [], + toolReports: [ + notAsked("claude"), + notAsked("cursor"), + notAsked("copilot"), + notAsked("opencode"), + notAsked("kilo"), + notAsked("codex"), + ], + }); + }); + + it("states the refusal in the words a person can act on", async () => { + const refused = new StubTelemetryEvidenceReader(); + refused.enabled = false; + const useCase = new ReadLocalCostUseCase( + new InMemoryTelemetrySink(), + claudeOnly(readerAnswering(new Map([[SESSION_ID, [CANDIDATE]]]))), + journalNaming(SESSION_ID), + NULL_PERSON_IDENTITY_READER, + refused + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT }); + + expect(result).toStrictEqual({ + sessions: [], + toolReports: [ + notAsked("claude"), + notAsked("cursor"), + notAsked("copilot"), + notAsked("opencode"), + notAsked("kilo"), + notAsked("codex"), + ], + refusedReason: + "measurement is refused — AIDD_TELEMETRY=0 or the project switch is off; nothing read, " + + "nothing stored", + }); + }); +}); + +describe("one session's answers, tool by tool", () => { + it("lists every tool once, in registry order, each with its own answer", async () => { + const result = await sweep( + claudeOnly(readerAnswering(new Map([[SESSION_ID, [CANDIDATE]]]))), + journalNaming(SESSION_ID) + ); + + expect(result.sessions).toStrictEqual([ + { + sessionId: SESSION_ID, + toolReports: [ + { tool: "claude", status: "found", recordsFound: 1, recordsStored: 1, sessionsFailed: 0 }, + CURSOR_NOT_COVERED, + notAsked("copilot"), + notAsked("opencode"), + KILO_NOT_COVERED, + notAsked("codex"), + ], + }, + ]); + }); + + it("reports not-found, never unreadable, for a covered tool that was given no reader", async () => { + const result = await sweep(new Map(), journalNaming(SESSION_ID)); + + expect(claudeReport(result.toolReports)).toStrictEqual({ + tool: "claude", + status: "not-found", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 0, + }); + }); + + it("carries no failure field at all for a tool whose every session read cleanly", async () => { + const result = await sweep( + claudeOnly(readerAnswering(new Map([[SESSION_ID, [CANDIDATE]]]))), + journalNaming(SESSION_ID) + ); + + expect(claudeReport(result.toolReports)).toStrictEqual({ + tool: "claude", + status: "found", + recordsFound: 1, + recordsStored: 1, + sessionsFailed: 0, + }); + }); +}); + +describe("the strongest answer a tool gave across a sweep", () => { + it("ranks empty above not-found, whichever session came first", async () => { + const result = await sweep( + claudeOnly( + readerAnswering( + new Map([ + ["s-a", "absent"], + ["s-b", []], + ]) + ) + ), + journalNaming("s-a", "s-b") + ); + + expect(claudeReport(result.toolReports).status).toBe("empty"); + }); + + it("ranks found above empty, so a session that billed nothing cannot hide one that did", async () => { + const result = await sweep( + claudeOnly( + readerAnswering( + new Map([ + ["s-a", []], + ["s-b", [{ ...CANDIDATE, vendor_id: "s-b" }]], + ]) + ) + ), + journalNaming("s-a", "s-b") + ); + + expect(claudeReport(result.toolReports)).toStrictEqual({ + tool: "claude", + status: "found", + recordsFound: 1, + recordsStored: 1, + sessionsFailed: 0, + }); + }); + + it("ranks not-found above not-asked, so a session that never asked cannot outrank one that looked", async () => { + const journals = new InMemoryRunJournalReader(); + journals.set("s-a", sessionJournal("s-a", "codex")); + journals.set("s-b", sessionJournal("s-b")); + + const result = await sweep( + claudeOnly(readerAnswering(new Map([["s-b", "absent"]]))), + journals + ); + + expect(claudeReport(result.toolReports)).toStrictEqual({ + tool: "claude", + status: "not-found", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 0, + }); + }); + + it("keeps the first of two unreadable sessions as the reason and the last as the failure", async () => { + const result = await sweep( + claudeOnly( + readerAnswering( + new Map([ + ["s-a", new Error("first")], + ["s-b", new Error("second")], + ]) + ) + ), + journalNaming("s-a", "s-b") + ); + + expect(claudeReport(result.toolReports)).toStrictEqual({ + tool: "claude", + status: "unreadable", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 2, + reason: "first", + failureReason: "second", + }); + }); + + it("ranks found above unreadable and still counts the session it could not read", async () => { + const result = await sweep( + claudeOnly( + readerAnswering( + new Map([ + ["s-a", new Error("boom")], + ["s-b", [{ ...CANDIDATE, vendor_id: "s-b" }]], + ]) + ) + ), + journalNaming("s-a", "s-b") + ); + + expect(claudeReport(result.toolReports)).toStrictEqual({ + tool: "claude", + status: "found", + recordsFound: 1, + recordsStored: 1, + sessionsFailed: 1, + failureReason: "boom", + }); + }); +}); + +describe("what counts as a correction of a stored turn", () => { + it("stores a reading that adds a counter the stored line never had", async () => { + const sink = new InMemoryTelemetrySink(); + const { cache_creation_tokens: _dropped, ...withoutCacheCreation } = STORED; + await sink.appendRecord(withoutCacheCreation, AT); + + expect(await readOne(sink, [CANDIDATE])).toBe(1); + }); + + it("drops a reading that lost a counter the stored line has, however large the rest", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord(STORED, AT); + const { cache_read_tokens: _dropped, ...withoutCacheRead } = CANDIDATE; + + expect(await readOne(sink, [{ ...withoutCacheRead, output_tokens: 900 }])).toBe(0); + }); + + it("drops a reading that grew one counter but shrank another", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord(STORED, AT); + + expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 900, input_tokens: 5 }])).toBe(0); + }); + + it("measures a correction against the largest stored reading, not the latest", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord({ ...STORED, output_tokens: 900 }, AT); + await sink.appendRecord(STORED, AT); + + expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 500 }])).toBe(0); + }); + + it("measures a correction against the largest stored reading, not the earliest", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord(STORED, AT); + await sink.appendRecord({ ...STORED, output_tokens: 900 }, AT); + + expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 500 }])).toBe(0); + }); + + it("measures against the earliest of two equally large stored readings", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord(STORED, AT); + await sink.appendRecord({ ...STORED, input_tokens: 20, output_tokens: 10 }, AT); + + expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 21 }])).toBe(1); + }); + + it("never lets a session total correct a request line that shares its turn id", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord(STORED, AT); + const sessionTotal: LocalCostCandidateRecord = { + kind: "session", + vendor_id: SESSION_ID, + vendor_field: "sessionId", + turn_id: TURN_ID, + turn_field: "requestId", + input_tokens: 100, + output_tokens: 200, + cache_read_tokens: 300, + cache_creation_tokens: 400, + }; + + expect(await readOne(sink, [sessionTotal])).toBe(0); + }); + + it("drops a turn already stored from an export, without a local-read line to measure against", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord({ ...STORED, provenance: "export" }, AT); + + expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 900 }])).toBe(0); + }); +}); + +class CountingSink extends InMemoryTelemetrySink { + vendorReads = 0; + listingFails = false; + + override async readRecordsForVendor(vendorId: string): Promise { + this.vendorReads += 1; + return super.readRecordsForVendor(vendorId); + } + + override async listDayFiles(): Promise { + if (this.listingFails) throw new Error("day files unlistable"); + return super.listDayFiles(); + } +} + +describe("housekeeping around a sweep", () => { + it("never consults the sink for a session whose reader returned nothing", async () => { + const sink = new CountingSink(); + + await readOne(sink, []); + + expect(sink.vendorReads).toBe(0); + }); + + it("warns, in its own words, when the day files cannot be listed, and still answers", async () => { + const sink = new CountingSink(); + sink.listingFails = true; + const logger = new CapturingLogger(); + const useCase = new ReadLocalCostUseCase( + sink, + claudeOnly(readerAnswering(new Map([[SESSION_ID, [CANDIDATE]]]))), + journalNaming(SESSION_ID), + NULL_PERSON_IDENTITY_READER, + EVIDENCE_READER, + undefined, + logger + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT }); + + expect(logger.warnMessages).toStrictEqual([ + "telemetry read: retention prune failed - day files unlistable", + ]); + expect(claudeReport(result.toolReports).recordsStored).toBe(1); + }); + + it("warns once per day file it could not delete, naming the file and the cause", async () => { + const sink = new InMemoryTelemetrySink(); + for (const day of ["2026-01-01", "2026-01-02", "2026-08-20"]) { + await sink.appendRecord(STORED, new Date(`${day}T00:00:00Z`)); + } + sink.undeletable.add("2026-01-01.jsonl"); + const logger = new CapturingLogger(); + const useCase = new ReadLocalCostUseCase( + sink, + claudeOnly(readerAnswering(new Map())), + journalNaming(SESSION_ID), + NULL_PERSON_IDENTITY_READER, + EVIDENCE_READER, + undefined, + logger, + 1 + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT }); + + expect(logger.warnMessages).toStrictEqual([ + "telemetry read: could not delete 2026-01-01.jsonl - cannot delete 2026-01-01.jsonl", + ]); + expect(sink.deletedFiles).toStrictEqual(["2026-01-02.jsonl"]); + }); + + it("leaves a dated record unattributed when no journal exists to place it in a step", async () => { + const sink = new InMemoryTelemetrySink(); + + await readOne(sink, [{ ...CANDIDATE, event_timestamp: "2026-08-20T10:02:00Z" }]); + + const [stored] = [...sink.files.values()].flat(); + expect(stored).toMatchObject({ step_attribution: "unattributed", step: undefined }); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/read-local-cost-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/read-local-cost-use-case.unit.test.ts index 388511732..dea20b167 100644 --- a/cli/tests/contexts/telemetry/application/read-local-cost-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/read-local-cost-use-case.unit.test.ts @@ -8,6 +8,7 @@ import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { ReadLocalCostUseCase } from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; import { mapCodexRolloutToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/codex-rollout.js"; import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; diff --git a/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts index 515fd004d..9204a849e 100644 --- a/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts @@ -6,6 +6,7 @@ import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { ReadLocalCostUseCase } from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; import { ReportCostUseCase } from "../../../../src/contexts/telemetry/application/report-cost-use-case.js"; import { toMicroUsd } from "../../../../src/contexts/telemetry/domain/cost-report.js"; @@ -892,3 +893,289 @@ describe("a report that catches the sink up first", () => { expect(reads).toBe(0); }); }); + +class RecordingTaskBacklogReader extends InMemoryTaskBacklogReader { + readonly asked: string[] = []; + + override async read(taskFolderPath: string) { + this.asked.push(taskFolderPath); + return super.read(taskFolderPath); + } +} + +describe("ReportCostUseCase — what it assembles for the report", () => { + const SESSION_AT = "2026-08-18T09:00:00Z"; + const NO_CAPABILITY_SUPPLY = { tokenCounters: true, amount: false } as const; + + let sink: InMemoryTelemetrySink; + let journals: InMemoryRunJournalReader; + let taskBacklog: RecordingTaskBacklogReader; + let logger: CapturingLogger; + + beforeEach(() => { + sink = new InMemoryTelemetrySink(); + journals = new InMemoryRunJournalReader(); + taskBacklog = new RecordingTaskBacklogReader(); + logger = new CapturingLogger(); + }); + + function report(read?: ReadLocalCostUseCase): ReportCostUseCase { + return new ReportCostUseCase( + sink, + journals, + new InMemoryPersonIdentityStore(), + new StubTelemetryEvidenceReader(), + taskBacklog, + logger, + read + ); + } + + function sessionJournal(vendorId: string, lines: Partial): RunJournal { + return { + boundaries: [], + filesWritten: [], + taskDeclarations: [], + session: { + type: "session_start", + at: SESSION_AT, + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + tool: "claude-code", + vendor_id: vendorId, + }, + ...lines, + }; + } + + it("declares every tool exactly as its profile does, limitation and refusal included", async () => { + const built = await report().execute({ ...BASE_OPTIONS, period: PERIOD }); + + const declarations = built.byTools.map(({ totals: _totals, ...declaration }) => declaration); + expect(declarations).toStrictEqual([ + { + tool: "claude", + coverage: "covered", + capability: { + localRead: { ...NO_CAPABILITY_SUPPLY, toolStatedStep: true, agentName: true }, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + { + tool: "cursor", + coverage: "not-covered", + reason: "It writes no token count in any file it produces.", + capability: { + localRead: null, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + { + tool: "copilot", + coverage: "covered", + reason: + "Its own file names outputTokens per turn, but session.shutdown carries all four " + + "counters for the whole session — a session total, never a sum of requests. Its four " + + "counters are measured disjoint, cached prompt included.", + capability: { + localRead: { ...NO_CAPABILITY_SUPPLY, toolStatedStep: false, agentName: false }, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + { + tool: "opencode", + coverage: "covered", + reason: + "Its four counters are measured disjoint for the anthropic provider and for one " + + "OpenAI-compatible provider whose cache was exercised — not confirmed for a " + + "provider that reports prompt tokens inclusive of the cached ones, which none " + + "captured here does.", + capability: { + localRead: { ...NO_CAPABILITY_SUPPLY, toolStatedStep: false, agentName: false }, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + { + tool: "kilo", + coverage: "not-covered", + reason: "Kilo OpenTelemetry is experimental and not yet supported by AIDD.", + capability: { + localRead: null, + export: null, + journalAttributable: false, + taskAttributable: false, + }, + }, + { + tool: "codex", + coverage: "covered", + capability: { + localRead: { ...NO_CAPABILITY_SUPPLY, toolStatedStep: false, agentName: false }, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + ]); + }); + + it("hands the generic filters to the report, which narrows to the value asked for", async () => { + await sink.appendRecord(record({ vendor_id: "s-1", model: "opus" }), STORED_ON); + await sink.appendRecord(record({ vendor_id: "s-2", model: "sonnet" }), STORED_ON); + + const built = await report().execute({ + ...BASE_OPTIONS, + period: PERIOD, + filters: { model: "opus" }, + }); + + expect(built.filters).toStrictEqual({ model: "opus" }); + expect(built.totals).toStrictEqual({ requests: 1 }); + }); + + it("reports through a journal whose session_start line is torn away", async () => { + journals.set("torn", { boundaries: [], filesWritten: [], taskDeclarations: [] }); + await sink.appendRecord(record({ vendor_id: "s-1" }), STORED_ON); + + const built = await report().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.totals).toStrictEqual({ requests: 1 }); + }); + + it("catches up past a journal whose session_start line is torn away", async () => { + journals.set("torn", { boundaries: [], filesWritten: [], taskDeclarations: [] }); + const read = new ReadLocalCostUseCase( + sink, + new Map([["claude", { read: async () => ({ records: [], sessionFound: false }) }]]), + journals, + NULL_PERSON_IDENTITY_READER, + new StubTelemetryEvidenceReader() + ); + + const built = await report(read).execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.totals).toStrictEqual({ requests: 0 }); + }); + + it("witnesses a moment through the session_start line alone when no other line is dated", async () => { + journals.set( + "s-1", + sessionJournal("s-1", { + filesWritten: [ + { type: "file_written", at: "not a moment", path: `aidd_docs/tasks/${TASK}/plan.md` }, + ], + }) + ); + await sink.appendRecord( + record({ vendor_id: "s-1", event_timestamp: "2026-08-18T09:00:00.500Z" }), + STORED_ON + ); + + const built = await report().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.byTasks).toStrictEqual([ + { task: TASK, attribution: "inferred", totals: { requests: 1 } }, + ]); + }); + + it("witnesses nothing from a journal whose every line is undated, rather than everything", async () => { + journals.set( + "s-1", + sessionJournal("s-1", { + session: { + type: "session_start", + at: "not a moment", + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + tool: "claude-code", + vendor_id: "s-1", + }, + }) + ); + await sink.appendRecord(record({ vendor_id: "s-1" }), STORED_ON); + + const built = await report().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.byTasks).toStrictEqual([{ reason: "no-declaration", totals: { requests: 1 } }]); + }); + + it("asks the backlog once per task folder, never per written file and never for a path outside a task", async () => { + journals.set( + "s-1", + sessionJournal("s-1", { + filesWritten: [ + { type: "file_written", at: SESSION_AT, path: `aidd_docs/tasks/${TASK}/plan.md` }, + { type: "file_written", at: SESSION_AT, path: `aidd_docs/tasks/${TASK}/spec.md` }, + { type: "file_written", at: SESSION_AT, path: "src/index.ts" }, + ], + }) + ); + + await report().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(taskBacklog.asked).toStrictEqual([taskFolderPathFromIdentity(TASK)]); + }); + + it("asks the backlog about a task the journal only declared, with no file written into it", async () => { + journals.set( + "s-1", + sessionJournal("s-1", { + boundaries: [{ type: "turn_end", at: "2026-08-18T10:00:00Z" }], + taskDeclarations: [ + { type: "task_declared", at: SESSION_AT, path: `aidd_docs/tasks/${TASK}/spec.md` }, + ], + }) + ); + + await report().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(taskBacklog.asked).toStrictEqual([taskFolderPathFromIdentity(TASK)]); + }); + + it("warns nothing when every reader answered", async () => { + journals.set("s-1", sessionJournal("s-1", {})); + const read = new ReadLocalCostUseCase( + sink, + new Map([["claude", { read: async () => ({ records: [], sessionFound: true }) }]]), + journals, + NULL_PERSON_IDENTITY_READER, + new StubTelemetryEvidenceReader() + ); + + await report(read).execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("names the tool, the session and the reader's own reason in one warning", async () => { + journals.set("s-1", sessionJournal("s-1", {})); + const read = new ReadLocalCostUseCase( + sink, + new Map([ + [ + "claude", + { + read: async () => { + throw new Error("the transcript directory is unreadable"); + }, + }, + ], + ]), + journals, + NULL_PERSON_IDENTITY_READER, + new StubTelemetryEvidenceReader() + ); + + await report(read).execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(logger.warnMessages).toStrictEqual([ + "telemetry report: claude could not be read for session s-1 - the transcript directory is unreadable", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts index e8931c588..f8fefab61 100644 --- a/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts @@ -193,3 +193,92 @@ describe("TelemetryOffUseCase — taking back what on installed", () => { expect(logger.allMessages.join("\n")).not.toContain("still calls the delegate"); }); }); + +describe("TelemetryOffUseCase — what it says, word for word", () => { + const SWITCH_LINE = `AIDD telemetry switch -> ${SWITCH_PATH}`; + + it("says only that the switch was already off, on a project that was never on", async () => { + const { logger, useCase } = buildUseCase(); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(logger.infoMessages).toStrictEqual([ + SWITCH_LINE, + "AIDD telemetry: already off, unchanged.", + ]); + }); + + it("says only that the switch was already off, on a switch file already off", async () => { + const seed = { [SWITCH_PATH]: JSON.stringify({ telemetry: { enabled: false } }) }; + const { logger, useCase } = buildUseCase(seed); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(logger.infoMessages).toStrictEqual([ + SWITCH_LINE, + "AIDD telemetry: already off, unchanged.", + ]); + }); + + it("says the switch is off once it turned it off", async () => { + const seed = { [SWITCH_PATH]: JSON.stringify({ telemetry: { enabled: true } }) }; + const { logger, useCase } = buildUseCase(seed); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(logger.infoMessages).toStrictEqual([SWITCH_LINE, "AIDD telemetry: off."]); + }); + + it("reads an unparseable switch file as off, rather than crashing on it", async () => { + const seed = { [SWITCH_PATH]: "not json" }; + const { fs, useCase } = buildUseCase(seed); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.switchChanged).toBe(false); + expect(fs.getFile(SWITCH_PATH)).toBe("not json"); + }); + + it("names the leftover export file and its keys, and what to do by hand", async () => { + const { logger, evidence, useCase } = buildUseCase(); + const settingsPath = join(PROJECT_ROOT, ".claude", "settings.local.json"); + evidence.leftoverExport = [ + { path: settingsPath, keys: ["CLAUDE_CODE_ENABLE_TELEMETRY", "OTEL_EXPORTER_OTLP_ENDPOINT"] }, + ]; + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(logger.warnMessages).toStrictEqual([ + `${settingsPath} still sets CLAUDE_CODE_ENABLE_TELEMETRY, OTEL_EXPORTER_OTLP_ENDPOINT — ` + + "this switch cannot touch a tool's own settings file. Delete these keys from its " + + "`env` block by hand to stop that export.", + ]); + }); + + it("names the lefthook job left calling the removed delegate, word for word", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + const logger = new CapturingLogger(); + const git: VersionControl = { + ...noGit, + removeCommitMessageDelegate: async () => ({ + removed: true, + hookManager: "lefthook", + managerCallsDelegate: true, + }), + }; + const useCase = new TelemetryOffUseCase(fs, logger, new StubTelemetryEvidenceReader(), git); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(logger.infoMessages).toStrictEqual([ + SWITCH_LINE, + "AIDD telemetry: already off, unchanged.", + "New commits will carry no AIDD-Session-Id trailer. Commits already made " + + "keep theirs — nothing here rewrites history.", + "lefthook.yml still calls the delegate this just removed — that file is not this " + + "CLI's to edit, so the job is left in place. Its own `[ -f ]` guard now finds " + + "nothing there, so it runs nothing; delete it from " + + "lefthook.yml by hand if you want it gone too.", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts index 35916d1b2..4b604e524 100644 --- a/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts @@ -6,6 +6,7 @@ import { SESSION_TRAILER_DELEGATE_FILE, SESSION_TRAILER_TOKEN, sessionTrailerDelegateScript, + sessionTrailerLefthookJob, } from "../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; import type { VersionControl } from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; import { TelemetryProjectScopeRequiresYesError } from "../../../../src/kernel/errors.js"; @@ -252,3 +253,125 @@ describe("TelemetryOnUseCase — making commits joinable to the session that mad expect(calls).toHaveLength(2); }); }); + +describe("TelemetryOnUseCase — what it says, word for word", () => { + const SWITCH_LINE = `AIDD telemetry switch -> ${SWITCH_PATH}`; + const IGNORED_LINE = + "Added aidd_docs/runs/ to .gitignore — the journal names no person, only the " + + "repository, the task folders written into, the skills run, and their timings. " + + "Delete that line to commit it instead."; + + it("names the command in the refusal it throws without --yes", async () => { + const { useCase } = buildUseCase(); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: false })).rejects.toThrow( + `aidd telemetry on writes the git-tracked ${SWITCH_PATH}, turning telemetry on for ` + + "everyone who clones. Pass --yes to confirm." + ); + }); + + it("says the journal was git-ignored, then that the switch is on, and nothing else", async () => { + const { logger, useCase } = buildUseCase(); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(logger.infoMessages).toStrictEqual([SWITCH_LINE, IGNORED_LINE, "AIDD telemetry: on."]); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("says only that the switch was already on, the second time", async () => { + const { logger, useCase } = buildUseCase(); + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + logger.reset(); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(logger.infoMessages).toStrictEqual([ + SWITCH_LINE, + "AIDD telemetry: already on, unchanged.", + ]); + }); + + it("warns which journal files git already tracks, and touches none of them", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + const logger = new CapturingLogger(); + const git: VersionControl = { + ...noGit, + listTrackedFiles: async () => ["aidd_docs/runs/a.jsonl", "aidd_docs/runs/b.jsonl"], + }; + const useCase = new TelemetryOnUseCase( + fs, + logger, + new GitignoreUseCase(fs), + git, + new InMemoryTelemetrySink() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(logger.warnMessages).toStrictEqual([ + "Already tracked by git — the repository, the task folders written into, the skills " + + "run, and their timings:\n aidd_docs/runs/a.jsonl\n aidd_docs/runs/b.jsonl\n" + + "Nothing removed or rewritten — your call.", + ]); + }); + + it("says what the trailer line it installed does, and the command undoing it", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + const logger = new CapturingLogger(); + const git: VersionControl = { + ...noGit, + installCommitMessageDelegate: async () => ({ lineAdded: true }), + }; + const useCase = new TelemetryOnUseCase( + fs, + logger, + new GitignoreUseCase(fs), + git, + new InMemoryTelemetrySink() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(logger.infoMessages).toStrictEqual([ + SWITCH_LINE, + IGNORED_LINE, + "Commits made by an AI session will carry an AIDD-Session-Id trailer, so what " + + "a session cost can be read per commit. A commit no session made carries nothing. " + + "`aidd telemetry off` removes it.", + "AIDD telemetry: on.", + ]); + }); + + it("prints the job to add by hand when lefthook owns the hook and does not call the delegate yet", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + const logger = new CapturingLogger(); + const git: VersionControl = { + ...noGit, + installCommitMessageDelegate: async () => ({ + lineAdded: false, + hookManager: "lefthook", + managerCallsDelegate: false, + }), + }; + const useCase = new TelemetryOnUseCase( + fs, + logger, + new GitignoreUseCase(fs), + git, + new InMemoryTelemetrySink() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(logger.infoMessages).toStrictEqual([ + SWITCH_LINE, + IGNORED_LINE, + "lefthook owns prepare-commit-msg here, so nothing was appended to it. Commits will " + + "not carry an AIDD-Session-Id trailer until you " + + "add this command under `prepare-commit-msg:` in lefthook.yml:\n\n" + + `${sessionTrailerLefthookJob(SESSION_TRAILER_DELEGATE_FILE)}\n`, + "AIDD telemetry: on.", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/tool-attribution.unit.test.ts b/cli/tests/contexts/telemetry/application/tool-attribution.unit.test.ts index e22146052..81c5a609b 100644 --- a/cli/tests/contexts/telemetry/application/tool-attribution.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/tool-attribution.unit.test.ts @@ -8,6 +8,7 @@ import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { ReadLocalCostUseCase } from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; import { mapClaudeCodeTranscriptToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; import type { SessionCostReader } from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; diff --git a/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts index 5d0f12206..10031afc9 100644 --- a/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts @@ -8,8 +8,10 @@ import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { buildCostReport, + type CostReport, type CostReportInput, type CostReportToolDeclaration, } from "../../../../src/contexts/telemetry/domain/cost-report.js"; @@ -312,6 +314,217 @@ describe("toCostReportEnvelope", () => { }); }); +const CAPABILITY = { + localRead: null, + export: null, + journalAttributable: false, + taskAttributable: false, +} as const; + +const BARE_REPORT: CostReport = { + fromDay: "2026-08-17", + toDay: "2026-08-17", + sessions: 1, + totals: { requests: 1 }, + bySteps: [{ attribution: "unattributed", totals: { requests: 1 } }], + byModels: [{ totals: { requests: 1 } }], + byAgents: [{ attribution: "not-stated", totals: { requests: 1 } }], + byPrompts: [{ totals: { requests: 1 } }], + byTools: [ + { tool: "codex", coverage: "covered", capability: CAPABILITY, totals: { requests: 1 } }, + ], + byProjects: [{ totals: { requests: 1 } }], + byTasks: [{ totals: { requests: 1 } }], + byBacklog: [{ totals: { requests: 1 } }], + byFlows: [{ attribution: "unattributed", totals: { requests: 1 } }], + byDays: [{ day: "2026-08-17", totals: { requests: 1 } }], + byPeople: [{ resolution: "none", identities: [], totals: { requests: 1 } }], + attributionMix: [{ attribution: "unattributed", totals: { requests: 1 } }], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, +}; + +const FULL_TOTALS = { + requests: 2, + costMicroUsd: 1500000, + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 30, + cacheCreationTokens: 40, +} as const; + +const FULL_TOTALS_RENDERED = { + requests: 2, + cost_micro_usd: 1500000, + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 30, + cache_creation_tokens: 40, +} as const; + +const FULL_REPORT: CostReport = { + fromDay: "2026-08-17", + toDay: "2026-08-18", + task: "2026_08/widgets", + filters: { project: "acme/widgets", model: "opus" }, + emptySelection: { filter: "model", value: "opus", known: true, combination: true }, + sessions: 2, + totals: FULL_TOTALS, + activeTimeSeconds: 754, + bySteps: [{ step: "implement", attribution: "tool-stated", totals: FULL_TOTALS }], + byModels: [{ model: "opus", totals: FULL_TOTALS }], + byAgents: [{ agent: "Explore", attribution: "tool-stated", totals: FULL_TOTALS }], + byPrompts: [{ prompt: "p-1", startedAt: "2026-08-17T09:00:00Z", totals: FULL_TOTALS }], + byTools: [ + { + tool: "copilot", + coverage: "not-covered", + reason: "A session total only.", + capability: CAPABILITY, + totals: FULL_TOTALS, + sessionTotals: { requests: 0, outputTokens: 5 }, + }, + ], + byProjects: [{ project: "acme/widgets", totals: FULL_TOTALS }], + byTasks: [ + { task: "2026_08/widgets", attribution: "declared", totals: FULL_TOTALS }, + { reason: "no-declaration", totals: FULL_TOTALS }, + ], + byBacklog: [ + { backlog: "STORY-7", totals: FULL_TOTALS }, + { declaration: "unreadable", totals: FULL_TOTALS }, + { reason: "no-journal", totals: FULL_TOTALS }, + ], + byFlows: [ + { + flow: "aidd-orchestrator:01-sdlc", + attribution: "journal-interval", + startedAt: "2026-08-17T08:00:00Z", + totals: FULL_TOTALS, + }, + ], + byDays: [{ day: "2026-08-17", totals: FULL_TOTALS }], + byPeople: [ + { + resolution: "mapped", + person: "ada", + displayName: "Ada L.", + identities: ["ada@example.test"], + totals: FULL_TOTALS, + }, + ], + attributionMix: [{ attribution: "tool-stated", totals: FULL_TOTALS }], + taskAttributionMix: [{ attribution: "declared", totals: FULL_TOTALS }], + undatedRecords: 3, + unreadableLines: 4, + identityUnusableCause: "unreadable", + measurementEnabled: false, +}; + +describe("toCostReportEnvelope renders a report value field for field", () => { + it("leaves every optional field out entirely, never present as undefined, when the report has none", () => { + expect(toCostReportEnvelope(BARE_REPORT)).toStrictEqual({ + cost_report_version: COST_REPORT_ENVELOPE_VERSION, + period: { from_day: "2026-08-17", to_day: "2026-08-17" }, + measurement_enabled: true, + sessions: 1, + totals: { requests: 1 }, + by_step: [{ attribution: "unattributed", totals: { requests: 1 } }], + by_model: [{ totals: { requests: 1 } }], + by_tool: [ + { + tool: "codex", + coverage: "covered", + capability: { + local_read: null, + export: null, + journal_attributable: false, + task_attributable: false, + }, + totals: { requests: 1 }, + }, + ], + by_project: [{ totals: { requests: 1 } }], + by_task: [{ totals: { requests: 1 } }], + by_backlog: [{ totals: { requests: 1 } }], + by_flow: [{ attribution: "unattributed", totals: { requests: 1 } }], + by_agent: [{ attribution: "not-stated", totals: { requests: 1 } }], + by_prompt: [{ totals: { requests: 1 } }], + by_day: [{ day: "2026-08-17", totals: { requests: 1 } }], + by_person: [{ resolution: "none", identities: [], totals: { requests: 1 } }], + attribution: [{ attribution: "unattributed", totals: { requests: 1 } }], + read: { undated_records: 0, unreadable_lines: 0 }, + }); + }); + + it("carries every optional field under its snake_case name when the report has them all", () => { + expect(toCostReportEnvelope(FULL_REPORT)).toStrictEqual({ + cost_report_version: COST_REPORT_ENVELOPE_VERSION, + period: { from_day: "2026-08-17", to_day: "2026-08-18" }, + measurement_enabled: false, + task: "2026_08/widgets", + filters: { project: "acme/widgets", model: "opus" }, + empty_selection: { filter: "model", value: "opus", known: true, combination: true }, + sessions: 2, + totals: FULL_TOTALS_RENDERED, + active_time_s: 754, + by_step: [{ step: "implement", attribution: "tool-stated", totals: FULL_TOTALS_RENDERED }], + by_model: [{ model: "opus", totals: FULL_TOTALS_RENDERED }], + by_tool: [ + { + tool: "copilot", + coverage: "not-covered", + reason: "A session total only.", + capability: { + local_read: null, + export: null, + journal_attributable: false, + task_attributable: false, + }, + totals: FULL_TOTALS_RENDERED, + session_totals: { requests: 0, output_tokens: 5 }, + }, + ], + by_project: [{ project: "acme/widgets", totals: FULL_TOTALS_RENDERED }], + by_task: [ + { task: "2026_08/widgets", attribution: "declared", totals: FULL_TOTALS_RENDERED }, + { reason: "no-declaration", totals: FULL_TOTALS_RENDERED }, + ], + by_backlog: [ + { backlog: "STORY-7", totals: FULL_TOTALS_RENDERED }, + { declaration: "unreadable", totals: FULL_TOTALS_RENDERED }, + { reason: "no-journal", totals: FULL_TOTALS_RENDERED }, + ], + by_flow: [ + { + flow: "aidd-orchestrator:01-sdlc", + attribution: "journal-interval", + started_at: "2026-08-17T08:00:00Z", + totals: FULL_TOTALS_RENDERED, + }, + ], + by_agent: [{ agent: "Explore", attribution: "tool-stated", totals: FULL_TOTALS_RENDERED }], + by_prompt: [ + { prompt: "p-1", started_at: "2026-08-17T09:00:00Z", totals: FULL_TOTALS_RENDERED }, + ], + by_day: [{ day: "2026-08-17", totals: FULL_TOTALS_RENDERED }], + by_person: [ + { + resolution: "mapped", + person: "ada", + display_name: "Ada L.", + identities: ["ada@example.test"], + totals: FULL_TOTALS_RENDERED, + }, + ], + attribution: [{ attribution: "tool-stated", totals: FULL_TOTALS_RENDERED }], + task_attribution: [{ attribution: "declared", totals: FULL_TOTALS_RENDERED }], + read: { undated_records: 3, unreadable_lines: 4, identity_unusable: "unreadable" }, + }); + }); +}); + describe("the two renderings are one computation", () => { const RECORDS: readonly TelemetrySinkRecord[] = [ record({ diff --git a/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts index cb2720a79..71edf7b29 100644 --- a/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts @@ -6,6 +6,7 @@ import { type CostReportInput, type CostReportSessionJournal, type CostTotals, + TotalsAccumulator, toMicroUsd, } from "../../../../src/contexts/telemetry/domain/cost-report.js"; import { @@ -1800,3 +1801,40 @@ describe("buildCostReport — a line on disk holds whatever it holds, not what a expect(built.activeTimeSeconds).toBe(42); }); }); + +describe("buildCostReport — a field with nothing to say is absent, never an undefined key", () => { + it("carries exactly the keys an unfiltered, request-only period fills", () => { + const built = report({ records: [request({ cost_usd: 1 })] }); + + expect(Object.keys(built)).toStrictEqual([ + "fromDay", + "toDay", + "sessions", + "totals", + "bySteps", + "byModels", + "byAgents", + "byPrompts", + "byTools", + "byProjects", + "byTasks", + "byBacklog", + "byFlows", + "byDays", + "byPeople", + "attributionMix", + "undatedRecords", + "unreadableLines", + "measurementEnabled", + ]); + }); +}); + +describe("TotalsAccumulator", () => { + it("builds exactly the requests count for a record carrying neither cost nor counters", () => { + const accumulator = new TotalsAccumulator(); + accumulator.add(request()); + + expect(accumulator.build()).toStrictEqual({ requests: 1 }); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts index 92517b360..1f59952b9 100644 --- a/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts @@ -75,6 +75,13 @@ describe("ORCHESTRATING_SKILLS — declared once, both capture spellings", () => expect(bareOrchestratingSkillNames()).toEqual(["00-async-dev", "01-sdlc", "02-backlog"]); }); + it("sorts the bare names whatever order the set was declared in", () => { + expect(bareOrchestratingSkillNames(new Set(["zz:b-flow", "b-flow", "a-flow"]))).toStrictEqual([ + "a-flow", + "b-flow", + ]); + }); + it("hands out a project's fourth orchestrator too, without anything else being told about it", () => { const extended = new Set([...ORCHESTRATING_SKILLS, "acme:03-release", "03-release"]); diff --git a/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript-fields.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript-fields.unit.test.ts new file mode 100644 index 000000000..50d411968 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript-fields.unit.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { mapClaudeCodeTranscriptToSinkRecords } from "../../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; + +const SID = "22222222-2222-4222-8222-222222222222"; + +const USAGE = { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 2, + cache_creation_input_tokens: 1, +}; + +const COUNTERS = { + input_tokens: 10, + output_tokens: 5, + cache_read_tokens: 2, + cache_creation_tokens: 1, +}; + +const BARE_RECORD = { + kind: "request", + vendor_id: SID, + vendor_field: "sessionId", + ...COUNTERS, +}; + +function chain(lines: readonly Record[]): string { + return lines.map((line) => JSON.stringify(line)).join("\n"); +} + +function assistantLine(overrides: Record = {}): Record { + return { + type: "assistant", + sessionId: SID, + message: { id: "msg_1", usage: USAGE }, + ...overrides, + }; +} + +function withUsage(usage: Record): string { + return chain([assistantLine({ message: { id: "msg_1", usage } })]); +} + +function skillCall(parts: readonly unknown[]): Record { + return { + type: "assistant", + uuid: "a1", + parentUuid: "u1", + sessionId: SID, + message: { content: parts }, + }; +} + +function promptedRecords(callParts: readonly unknown[]) { + return mapClaudeCodeTranscriptToSinkRecords( + chain([ + { type: "user", uuid: "u1", promptId: "p-abc" }, + skillCall(callParts), + assistantLine({ uuid: "a2", parentUuid: "a1" }), + ]) + ); +} + +describe("a billed line is one that carries every counter as a number", () => { + it("yields no record when input_tokens is missing", () => { + const { input_tokens: _dropped, ...usage } = USAGE; + + expect(mapClaudeCodeTranscriptToSinkRecords(withUsage(usage))).toStrictEqual([]); + }); + + it("yields no record when cache_read_input_tokens is missing", () => { + const { cache_read_input_tokens: _dropped, ...usage } = USAGE; + + expect(mapClaudeCodeTranscriptToSinkRecords(withUsage(usage))).toStrictEqual([]); + }); + + it("yields no record when output_tokens is missing", () => { + const { output_tokens: _dropped, ...usage } = USAGE; + + expect(mapClaudeCodeTranscriptToSinkRecords(withUsage(usage))).toStrictEqual([]); + }); + + it("yields no record when a counter is a string rather than a number", () => { + const content = withUsage({ ...USAGE, input_tokens: "10" }); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([]); + }); +}); + +describe("a billed line names its session and is an assistant turn", () => { + it("yields no record when sessionId is absent", () => { + const content = chain([assistantLine({ sessionId: undefined })]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([]); + }); + + it("yields no record when sessionId is not a string", () => { + const content = chain([assistantLine({ sessionId: 123 })]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([]); + }); + + it("yields no record for a user line, even one carrying counters", () => { + const content = chain([assistantLine({ type: "user" })]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([]); + }); +}); + +describe("the record carries exactly the keys the line states", () => { + it("holds identity and counters alone when the line names no request, model, effort or moment", () => { + const content = chain([assistantLine()]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([BARE_RECORD]); + }); + + it("names the agent only when the line is a sidechain", () => { + const content = chain([assistantLine({ attributionAgent: "Explore" })]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([BARE_RECORD]); + }); + + it("names the plugin beside the skill the line attributes", () => { + const content = chain([ + assistantLine({ attributionSkill: "aidd-dev:01-plan", attributionPlugin: "aidd-dev" }), + ]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([ + { ...BARE_RECORD, step: "aidd-dev:01-plan", step_plugin: "aidd-dev" }, + ]); + }); + + it("names no plugin when the line attributes a plugin but no skill", () => { + const content = chain([assistantLine({ attributionPlugin: "aidd-dev" })]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([BARE_RECORD]); + }); + + it("carries the prompt id with no prompt_skill key when the prompt invoked no skill", () => { + const content = chain([ + { type: "user", uuid: "u1", promptId: "p-abc" }, + assistantLine({ uuid: "a1", parentUuid: "u1" }), + ]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([ + { ...BARE_RECORD, prompt_id: "p-abc" }, + ]); + }); +}); + +describe("reading the Skill call that names a prompt's step", () => { + it("passes over a Skill call that carries no input and reads the next one", () => { + const records = promptedRecords([ + { type: "tool_use", name: "Skill" }, + { type: "tool_use", name: "Skill", input: { skill: "aidd-dev:02-implement" } }, + ]); + + expect(records[0]?.prompt_skill).toBe("aidd-dev:02-implement"); + }); + + it("passes over a null content part rather than throwing", () => { + const records = promptedRecords([ + null, + { type: "tool_use", name: "Skill", input: { skill: "aidd-dev:02-implement" } }, + ]); + + expect(records[0]?.prompt_skill).toBe("aidd-dev:02-implement"); + }); + + it("ignores a part named Skill whose type is not tool_use", () => { + const records = promptedRecords([ + { type: "text", name: "Skill", input: { skill: "aidd-dev:01-plan" } }, + ]); + + expect(records).toStrictEqual([{ ...BARE_RECORD, prompt_id: "p-abc" }]); + }); +}); + +describe("two lines restating one call", () => { + it("collapse on message.id even when neither carries a requestId", () => { + const content = chain([ + assistantLine({ message: { id: "msg_1", usage: { ...USAGE, output_tokens: 1 } } }), + assistantLine({ message: { id: "msg_1", usage: { ...USAGE, output_tokens: 9 } } }), + ]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([ + { ...BARE_RECORD, output_tokens: 9 }, + ]); + }); + + it("collapse on their text, whitespace aside, when neither carries any id", () => { + const line = JSON.stringify(assistantLine({ message: { usage: USAGE } })); + + expect(mapClaudeCodeTranscriptToSinkRecords(`${line}\n ${line} \n`)).toStrictEqual([ + BARE_RECORD, + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts index a82d82c14..5369556bd 100644 --- a/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts @@ -238,3 +238,156 @@ describe("a token_count re-emitted with an unmoved cumulative", () => { expect(mapCodexRolloutToSinkRecords(withoutTotal)[0]?.output_tokens).toBe(20); }); }); + +const sessionMeta = (id: unknown) => JSON.stringify({ type: "session_meta", payload: { id } }); +const turnContext = (payload: Record, timestamp?: string) => + JSON.stringify({ type: "turn_context", timestamp, payload }); +const counted = (last: Record, total?: Record) => + JSON.stringify({ + type: "event_msg", + payload: { type: "token_count", info: { last_token_usage: last, total_token_usage: total } }, + }); +const rolloutOf = (...lines: string[]) => mapCodexRolloutToSinkRecords(lines.join("\n")); + +const BARE_TURN = { + kind: "request", + vendor_id: "s-1", + vendor_field: "session_meta.id", + turn_id: "t-1", + turn_field: "turn_id", +}; + +describe("a turn is recorded only once its session and turn are named", () => { + it("yields no record when session_meta.id is not a string", () => { + const records = rolloutOf( + sessionMeta(123), + turnContext({ turn_id: "t-1" }), + counted({ output_tokens: 3 }) + ); + + expect(records).toStrictEqual([]); + }); + + it("yields no record when no session_meta line names the session", () => { + const records = rolloutOf(turnContext({ turn_id: "t-1" }), counted({ output_tokens: 3 })); + + expect(records).toStrictEqual([]); + }); + + it("yields no record for a turn_context that names no turn_id", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ model: "gpt-5.4" }), + counted({ output_tokens: 3 }) + ); + + expect(records).toStrictEqual([]); + }); +}); + +describe("the record carries exactly the counters the turn stated", () => { + it.each([ + ["input_tokens", { input_tokens: 7 }, { input_tokens: 7 }], + ["output_tokens", { output_tokens: 3 }, { output_tokens: 3 }], + ["cached_input_tokens", { cached_input_tokens: 5 }, { cache_read_tokens: 5 }], + ["cache_write_input_tokens", { cache_write_input_tokens: 4 }, { cache_creation_tokens: 4 }], + ])("holds identity plus %s alone when that is all the turn stated", (_name, last, stored) => { + const records = rolloutOf(sessionMeta("s-1"), turnContext({ turn_id: "t-1" }), counted(last)); + + expect(records).toStrictEqual([{ ...BARE_TURN, ...stored }]); + }); + + it("leaves a counter unset rather than coercing a string figure", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + counted({ input_tokens: "7", output_tokens: 3 }) + ); + + expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 3 }]); + }); + + it("adds cache_write_input_tokens across the turn's events", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + counted({ cache_write_input_tokens: 7 }), + counted({ cache_write_input_tokens: 5 }) + ); + + expect(records).toStrictEqual([{ ...BARE_TURN, cache_creation_tokens: 12 }]); + }); +}); + +describe("which lines count", () => { + it("counts both events when each states a cumulative with no figure in it", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + counted({ output_tokens: 10 }, {}), + counted({ output_tokens: 10 }, {}) + ); + + expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 20 }]); + }); + + it("counts a re-emitted event once even when its unmoved cumulative omits a metric", () => { + const total = { input_tokens: 100, cached_input_tokens: 0, output_tokens: 10 }; + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + counted({ output_tokens: 10 }, total), + counted({ output_tokens: 10 }, total) + ); + + expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 10 }]); + }); + + it("ignores a token_count carried by a line that is not an event_msg", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + JSON.stringify({ + type: "response_item", + payload: { type: "token_count", info: { last_token_usage: { output_tokens: 3 } } }, + }) + ); + + expect(records).toStrictEqual([]); + }); + + it("ignores an event_msg that is not a token_count, even one carrying usage", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + JSON.stringify({ + type: "event_msg", + payload: { type: "task_started", info: { last_token_usage: { output_tokens: 3 } } }, + }) + ); + + expect(records).toStrictEqual([]); + }); + + it("ignores a token_count that carries no info rather than throwing", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + JSON.stringify({ type: "event_msg", payload: { type: "token_count" } }), + counted({ output_tokens: 3 }) + ); + + expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 3 }]); + }); + + it("skips a half-written line rather than throwing", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + '{"type":"event_msg","payload":{"type":"token_co', + counted({ output_tokens: 3 }) + ); + + expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 3 }]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts index 320013f78..3faa654e0 100644 --- a/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts @@ -120,3 +120,57 @@ describe("mapCopilotEventsToSinkRecords", () => { expect(mapCopilotEventsToSinkRecords.length).toBe(2); }); }); + +const TOKEN_DETAILS = { + input: { tokenCount: 10 }, + output: { tokenCount: 42 }, + cache_read: { tokenCount: 0 }, + cache_write: { tokenCount: 21070 }, +}; + +function shutdown(line: Record): string { + return JSON.stringify({ type: "session.shutdown", ...line }); +} + +describe("a shutdown counts only when every counter is stated as a number", () => { + it.each(["input", "output", "cache_read", "cache_write"] as const)( + "yields nothing, without throwing, when tokenDetails lacks %s", + (missing) => { + const { [missing]: _dropped, ...tokenDetails } = TOKEN_DETAILS; + + expect(mapCopilotEventsToSinkRecords(shutdown({ data: { tokenDetails } }), SESSION)).toEqual( + [] + ); + } + ); + + it("yields nothing when a counter is a string rather than a number", () => { + const tokenDetails = { ...TOKEN_DETAILS, input: { tokenCount: "10" } }; + + expect(mapCopilotEventsToSinkRecords(shutdown({ data: { tokenDetails } }), SESSION)).toEqual( + [] + ); + }); + + it("yields nothing for a shutdown that carries no data at all", () => { + expect(mapCopilotEventsToSinkRecords(shutdown({}), SESSION)).toEqual([]); + }); +}); + +describe("the record carries exactly the keys the shutdown states", () => { + it("holds identity and counters alone when the shutdown names no turn and no moment", () => { + const content = shutdown({ id: 7, data: { tokenDetails: TOKEN_DETAILS } }); + + expect(mapCopilotEventsToSinkRecords(content, SESSION)).toStrictEqual([ + { + kind: "session", + vendor_id: SESSION, + vendor_field: "sessionId", + input_tokens: 10, + output_tokens: 42, + cache_read_tokens: 0, + cache_creation_tokens: 21070, + }, + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts index f0e430173..21875dadb 100644 --- a/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts @@ -159,4 +159,56 @@ describe("mapOpencodeExportToSinkRecords", () => { expect(mapOpencodeExportToSinkRecords(null, SESSION_ID)).toEqual([]); expect(mapOpencodeExportToSinkRecords({ messages: [] }, SESSION_ID)).toEqual([]); }); + + it("skips a null message entry rather than throwing", () => { + expect(mapOpencodeExportToSinkRecords({ messages: [null] }, SESSION_ID)).toEqual([]); + }); +}); + +const BARE_MESSAGE = { + kind: "request", + vendor_id: SESSION_ID, + vendor_field: "sessionID", + turn_id: "msg_1", + turn_field: "id", +}; + +function recordsOf(info: Record) { + return mapOpencodeExportToSinkRecords( + { messages: [{ info: { id: "msg_1", ...info } }] }, + SESSION_ID + ); +} + +describe("the record carries exactly the keys the message states", () => { + it("holds identity alone when a billed message states no counter, model or time", () => { + expect(recordsOf({ tokens: { total: 5 } })).toStrictEqual([BARE_MESSAGE]); + }); + + it("reads a message whose tokens carry no cache block, without throwing", () => { + expect(recordsOf({ tokens: { total: 5, input: 3, output: 2 } })).toStrictEqual([ + { ...BARE_MESSAGE, input_tokens: 3, output_tokens: 2 }, + ]); + }); + + it("leaves a counter unset rather than storing a string figure", () => { + expect(recordsOf({ tokens: { total: 5, input: "3", output: 2 } })).toStrictEqual([ + { ...BARE_MESSAGE, output_tokens: 2 }, + ]); + }); + + it("names no model and no turn when neither is a string", () => { + const records = mapOpencodeExportToSinkRecords( + { messages: [{ info: { id: 42, modelID: 42, tokens: { total: 5 } } }] }, + SESSION_ID + ); + + expect(records).toStrictEqual([ + { kind: "request", vendor_id: SESSION_ID, vendor_field: "sessionID" }, + ]); + }); + + it.each([0, -1])("carries no moment for a creation time of %s", (created) => { + expect(recordsOf({ tokens: { total: 5 }, time: { created } })).toStrictEqual([BARE_MESSAGE]); + }); }); diff --git a/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts b/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts index 556ff2317..28c29a042 100644 --- a/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildClosedIntervals, type IntervalClosure, + momentFallsWithin, timed, } from "../../../../src/contexts/telemetry/domain/journal-intervals.js"; @@ -114,3 +115,26 @@ describe("IntervalClosure — the three ways an interval's end is reached", () = ]); }); }); + +describe("momentFallsWithin — over more than one interval", () => { + const FIRST = { + startMs: Date.parse("2026-01-01T00:00:00.000Z"), + endMs: Date.parse("2026-01-02T00:00:00.000Z"), + }; + const SECOND = { + startMs: Date.parse("2026-01-05T00:00:00.000Z"), + endMs: Date.parse("2026-01-06T00:00:00.000Z"), + }; + + it("holds for a moment inside one interval alone", () => { + expect(momentFallsWithin([FIRST, SECOND], "2026-01-05T12:00:00.000Z")).toBe(true); + }); + + it("holds for a moment at the very instant an interval opens", () => { + expect(momentFallsWithin([FIRST, SECOND], "2026-01-05T00:00:00.000Z")).toBe(true); + }); + + it("fails for a moment in the gap between two intervals", () => { + expect(momentFallsWithin([FIRST, SECOND], "2026-01-03T00:00:00.000Z")).toBe(false); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/metrics-contract.unit.test.ts b/cli/tests/contexts/telemetry/domain/metrics-contract.unit.test.ts index bbd09f1a4..259256c5e 100644 --- a/cli/tests/contexts/telemetry/domain/metrics-contract.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/metrics-contract.unit.test.ts @@ -10,6 +10,7 @@ import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { ReadLocalCostUseCase } from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; import type { LocalCostCandidateRecord } from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; import { NULL_PERSON_IDENTITY_READER } from "../../../helpers/ports/in-memory-person-identity-reader.js"; diff --git a/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts b/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts index ee12d50e8..9ea401edd 100644 --- a/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts @@ -104,6 +104,19 @@ describe("resolvePerson", () => { expect(resolvePerson(null, undefined).resolution).toBe("none"); }); + it("reads an empty identifier as none carried, naming this machine's own person", () => { + expect(resolvePerson(identityWithAlsoMe(), "")).toStrictEqual({ + resolution: "this-machine", + personId: "person-a", + displayName: "Ada", + identities: ["person-a", "claude-machine-1", "codex-machine-2"], + }); + }); + + it("reads an empty identifier against no identity as none, never as an unresolved blank", () => { + expect(resolvePerson(null, "")).toStrictEqual({ resolution: "none", identities: [] }); + }); + it("a resolved person carries back every identity behind it, including its canonical one", () => { const resolved = resolvePerson(identityWithAlsoMe(), "codex-machine-2"); diff --git a/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts b/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts index aa7911260..306161d76 100644 --- a/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts @@ -80,6 +80,21 @@ describe("resolveReportPeriod", () => { expect(() => resolveReportPeriod({ from: "notaday" }, TODAY)).toThrow(/--from.*YYYY-MM-DD/u); }); + it("refuses a well-shaped day no calendar has as an invalid day, never as a date routine's own error", () => { + expect(() => resolveReportPeriod({ from: "2026-13-01" }, TODAY)).toThrow(InvalidReportDayError); + }); + + it("names --to when the end is not a day", () => { + expect(() => resolveReportPeriod({ to: "notaday" }, TODAY)).toThrow(/--to.*YYYY-MM-DD/u); + }); + + it("accepts the longest span, ten years, and counts it back from the end", () => { + expect(resolveReportPeriod({ to: "2026-08-21", days: "3650" }, TODAY)).toEqual({ + fromDay: "2016-08-24", + toDay: "2026-08-21", + }); + }); + it("refuses a span that is not a whole number of days", () => { for (const value of ["0", "-1", "1.5", "many", "4000"]) { expect(() => resolveReportPeriod({ days: value }, TODAY), value).toThrow( diff --git a/cli/tests/contexts/telemetry/domain/report/axes/day-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/day-rows.unit.test.ts new file mode 100644 index 000000000..5e73658ca --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/day-rows.unit.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { dayRange } from "../../../../../../src/contexts/telemetry/domain/report/axes/day-rows.js"; + +describe("dayRange", () => { + it("lists each UTC day from the first to the last exactly once", () => { + expect(dayRange("2026-08-30", "2026-09-02")).toStrictEqual([ + "2026-08-30", + "2026-08-31", + "2026-09-01", + "2026-09-02", + ]); + }); + + it("lists a one-day period as that one day", () => { + expect(dayRange("2026-08-18", "2026-08-18")).toStrictEqual(["2026-08-18"]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/flow-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/flow-rows.unit.test.ts new file mode 100644 index 000000000..ded85ac1f --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/flow-rows.unit.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + type CostReportSessionJournal, + TotalsAccumulator, +} from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; +import type { FlowInterval } from "../../../../../../src/contexts/telemetry/domain/flow-attribution.js"; +import { + allFlowIntervalsByVendorId, + type FlowRowKey, + flowKeyOf, + flowRows, +} from "../../../../../../src/contexts/telemetry/domain/report/axes/flow-rows.js"; +import type { TelemetrySinkRecord } from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +function record(overrides: Partial = {}): TelemetrySinkRecord { + return { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", + ...overrides, + }; +} + +function totalsOf(costUsd: number): TotalsAccumulator { + const accumulator = new TotalsAccumulator(); + accumulator.add(record({ cost_usd: costUsd })); + return accumulator; +} + +function sdlcRunAt(startedAt: string): FlowInterval { + const startMs = Date.parse(startedAt); + return { + skill: "aidd-orchestrator:01-sdlc", + startMs, + endMs: startMs + 60_000, + closedBy: "journal-end", + }; +} + +describe("allFlowIntervalsByVendorId", () => { + it("gives a session that opened no flow no entry at all", () => { + const journal: CostReportSessionJournal = { + vendorId: "s-1", + tool: "claude", + writtenPaths: [], + taskIntervals: [], + flowIntervals: [], + }; + + expect(allFlowIntervalsByVendorId([journal])).toStrictEqual(new Map()); + }); +}); + +describe("flowKeyOf, for a record no interval covers", () => { + const outside = flowKeyOf(record(), new Map()); + + it("keys a tool-stated step that orchestrates nothing on the outside-every-flow row", () => { + const key = flowKeyOf( + record({ step_attribution: "tool-stated", step: "aidd-dev:01-plan" }), + new Map() + ); + + expect(typeof key).toBe("symbol"); + expect(key).toBe(outside); + }); + + it("keys a tool-stated orchestrating step on the skill's own name", () => { + const key = flowKeyOf( + record({ step_attribution: "tool-stated", step: "aidd-orchestrator:01-sdlc" }), + new Map() + ); + + expect(key).toBe("aidd-orchestrator:01-sdlc"); + }); +}); + +describe("flowRows", () => { + it("breaks a tie between two runs of one skill on the moment each started", () => { + const flows = new Map([ + [sdlcRunAt("2026-08-18T11:00:00Z"), totalsOf(1)], + [sdlcRunAt("2026-08-18T10:00:00Z"), totalsOf(1)], + ]); + + expect(flowRows(flows).map((row) => row.startedAt)).toStrictEqual([ + "2026-08-18T10:00:00Z", + "2026-08-18T11:00:00Z", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/person-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/person-rows.unit.test.ts new file mode 100644 index 000000000..51cba9c74 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/person-rows.unit.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { TotalsAccumulator } from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; +import type { ResolvedPerson } from "../../../../../../src/contexts/telemetry/domain/person-resolution.js"; +import { + type PersonGroup, + personGroupKey, + personRawIdOf, + personRows, +} from "../../../../../../src/contexts/telemetry/domain/report/axes/person-rows.js"; +import { + parseTelemetrySinkLine, + type TelemetrySinkRecord, +} from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +const BASE: TelemetrySinkRecord = { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", +}; + +const NONE: ResolvedPerson = { resolution: "none", identities: [] }; + +function group(resolved: ResolvedPerson, costUsd: number): PersonGroup { + const totals = new TotalsAccumulator(); + totals.add({ ...BASE, cost_usd: costUsd }); + return { resolved, totals }; +} + +function rowsOf(...groups: readonly PersonGroup[]) { + return personRows(new Map(groups.map((entry, index) => [`k${index}`, entry]))); +} + +describe("personRawIdOf", () => { + it("reads an empty person_id as no identifier", () => { + expect(personRawIdOf({ ...BASE, person_id: "" })).toBeUndefined(); + }); + + it("reads a person_id stored as a number as no identifier", () => { + const stored = parseTelemetrySinkLine(JSON.stringify({ ...BASE, person_id: 42 })); + + expect(personRawIdOf(stored)).toBeUndefined(); + }); + + it("reads a real identifier as itself", () => { + expect(personRawIdOf({ ...BASE, person_id: "claude-machine-1" })).toBe("claude-machine-1"); + }); +}); + +describe("personGroupKey", () => { + it("keys an unresolved person on its raw identifier", () => { + expect(personGroupKey({ resolution: "unresolved", identities: ["raw-1"] })).toBe("raw-1"); + }); + + it("keys this machine's own person on the shared no-identifier key, never its personId", () => { + const key = personGroupKey({ resolution: "this-machine", personId: "p", identities: ["p"] }); + + expect(typeof key).toBe("symbol"); + expect(key).toBe(personGroupKey(NONE)); + }); + + it("keys a mapped person carrying no personId on the shared no-identifier key", () => { + const key = personGroupKey({ resolution: "mapped", identities: ["raw-1"] }); + + expect(typeof key).toBe("symbol"); + expect(key).toBe(personGroupKey(NONE)); + }); + + it("keys an unresolved person with no identifier on the shared no-identifier key", () => { + const key = personGroupKey({ resolution: "unresolved", identities: [] }); + + expect(typeof key).toBe("symbol"); + expect(key).toBe(personGroupKey(NONE)); + }); +}); + +describe("personRows", () => { + it("carries neither person nor displayName on a row that resolved to nobody", () => { + expect(rowsOf(group(NONE, 1))).toStrictEqual([ + { resolution: "none", identities: [], totals: { requests: 1, costMicroUsd: 1_000_000 } }, + ]); + }); + + it("reads mapped, this-machine, unresolved, none, whatever their sizes", () => { + const rows = rowsOf( + group(NONE, 4), + group({ resolution: "unresolved", identities: ["raw"] }, 3), + group({ resolution: "this-machine", personId: "me", identities: ["me"] }, 2), + group({ resolution: "mapped", personId: "her", identities: ["her"] }, 1) + ); + + expect(rows.map((row) => row.resolution)).toStrictEqual([ + "mapped", + "this-machine", + "unresolved", + "none", + ]); + }); + + it("breaks a tie between two mapped people on the person, not the evidence", () => { + const rows = rowsOf( + group({ resolution: "mapped", personId: "b", identities: ["aaa"] }, 1), + group({ resolution: "mapped", personId: "a", identities: ["zzz"] }, 1) + ); + + expect(rows.map((row) => row.person)).toStrictEqual(["a", "b"]); + }); + + it("breaks a tie between two unresolved identifiers on the identifier", () => { + const rows = rowsOf( + group({ resolution: "unresolved", identities: ["raw-b"] }, 1), + group({ resolution: "unresolved", identities: ["raw-a"] }, 1) + ); + + expect(rows.map((row) => row.identities)).toStrictEqual([["raw-a"], ["raw-b"]]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/record-stated-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/record-stated-rows.unit.test.ts new file mode 100644 index 000000000..b89a0ef93 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/record-stated-rows.unit.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { TotalsAccumulator } from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; +import { + type AgentKey, + agentRows, + modelKeyOf, + modelRows, + type PromptGroup, + projectRows, + promptRows, +} from "../../../../../../src/contexts/telemetry/domain/report/axes/record-stated-rows.js"; +import type { TelemetrySinkRecord } from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +const BASE: TelemetrySinkRecord = { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", +}; + +function totalsOf(costUsd: number): TotalsAccumulator { + const accumulator = new TotalsAccumulator(); + accumulator.add({ ...BASE, cost_usd: costUsd }); + return accumulator; +} + +describe("modelKeyOf", () => { + it("keys a record naming no model on a symbol, never on undefined", () => { + expect(typeof modelKeyOf(BASE)).toBe("symbol"); + }); +}); + +describe("a tie between two rows of equal size", () => { + it("is broken on the project name", () => { + const rows = projectRows( + new Map([ + ["proj-b", totalsOf(1)], + ["proj-a", totalsOf(1)], + ]) + ); + + expect(rows.map((row) => row.project)).toStrictEqual(["proj-a", "proj-b"]); + }); + + it("is broken on the agent name", () => { + const rows = agentRows( + new Map([ + ["Plan", totalsOf(1)], + ["Explore", totalsOf(1)], + ]) + ); + + expect(rows.map((row) => row.agent)).toStrictEqual(["Explore", "Plan"]); + }); + + it("is broken on the prompt id", () => { + const rows = promptRows( + new Map([ + ["p-b", { totals: totalsOf(1) }], + ["p-a", { totals: totalsOf(1) }], + ]) + ); + + expect(rows.map((row) => row.prompt)).toStrictEqual(["p-a", "p-b"]); + }); + + it("is broken on the model name", () => { + const rows = modelRows( + new Map([ + ["claude-sonnet-5", totalsOf(1)], + ["claude-opus-5", totalsOf(1)], + ]) + ); + + expect(rows.map((row) => row.model)).toStrictEqual(["claude-opus-5", "claude-sonnet-5"]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/step-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/step-rows.unit.test.ts new file mode 100644 index 000000000..0cf67c738 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/step-rows.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { TotalsAccumulator } from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; +import { + type StepGroup, + stepRowKey, + stepRows, +} from "../../../../../../src/contexts/telemetry/domain/report/axes/step-rows.js"; +import type { TelemetrySinkRecord } from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +const BASE: TelemetrySinkRecord = { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", +}; + +function group(overrides: Partial): StepGroup { + const totals = new TotalsAccumulator(); + totals.add({ ...BASE, cost_usd: 1 }); + return { attribution: "unattributed", totals, ...overrides }; +} + +describe("stepRowKey", () => { + it("joins the attribution and the step with one space", () => { + expect(stepRowKey({ ...BASE, step_attribution: "tool-stated", step: "aidd-dev:01-plan" })).toBe( + "tool-stated aidd-dev:01-plan" + ); + }); + + it("leaves the step empty after the space when none was found", () => { + expect(stepRowKey(BASE)).toBe("unattributed "); + }); +}); + +describe("stepRows", () => { + it("carries no step key at all on the unattributed row", () => { + expect(stepRows(new Map([["unattributed ", group({})]]))).toStrictEqual([ + { attribution: "unattributed", totals: { requests: 1, costMicroUsd: 1_000_000 } }, + ]); + }); + + it("breaks a tie between two steps on the step name", () => { + const rows = stepRows( + new Map([ + ["b", group({ attribution: "tool-stated", step: "aidd-dev:02-implement" })], + ["a", group({ attribution: "tool-stated", step: "aidd-dev:01-plan" })], + ]) + ); + + expect(rows.map((row) => row.step)).toStrictEqual([ + "aidd-dev:01-plan", + "aidd-dev:02-implement", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/task-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/task-rows.unit.test.ts new file mode 100644 index 000000000..3fb08ba97 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/task-rows.unit.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { + type CostReportSessionJournal, + TotalsAccumulator, +} from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; +import { + backlogKeyOf, + backlogRows, + type TaskGroup, + taskRowKeyOf, + taskRowOf, + taskRows, +} from "../../../../../../src/contexts/telemetry/domain/report/axes/task-rows.js"; +import type { TaskBacklogDeclaration } from "../../../../../../src/contexts/telemetry/domain/task-backlog-link.js"; +import type { TelemetrySinkRecord } from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +const FROM_MS = Date.parse("2026-08-18T10:00:00Z"); +const TO_MS = Date.parse("2026-08-18T11:00:00Z"); +const TASK_PATH = "aidd_docs/tasks/2026_08/refactor-sink/plan.md"; +const TASK = "2026_08/refactor-sink"; + +function record(overrides: Partial = {}): TelemetrySinkRecord { + return { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", + event_timestamp: "2026-08-18T10:30:00Z", + ...overrides, + }; +} + +function journal(overrides: Partial = {}): CostReportSessionJournal { + return { + vendorId: "s-1", + tool: "claude", + writtenPaths: [TASK_PATH], + taskIntervals: [], + flowIntervals: [], + witnessed: { fromMs: FROM_MS, toMs: TO_MS }, + ...overrides, + }; +} + +function totalsOf(costUsd: number): TotalsAccumulator { + const accumulator = new TotalsAccumulator(); + accumulator.add(record({ cost_usd: costUsd })); + return accumulator; +} + +function rowOf(target: TelemetrySinkRecord, sessionJournal: CostReportSessionJournal | undefined) { + return taskRowOf( + target, + new Map([["s-1", sessionJournal?.taskIntervals ?? []]]), + new Map(sessionJournal === undefined ? [] : [["s-1", sessionJournal]]) + ); +} + +describe("taskRowKeyOf", () => { + it("joins the attribution and the task with one space", () => { + expect(taskRowKeyOf({ task: TASK, attribution: "declared" })).toBe(`declared ${TASK}`); + }); + + it("keeps a reason as its own key", () => { + expect(taskRowKeyOf("journal-silent")).toBe("journal-silent"); + }); +}); + +describe("taskRowOf, when the intervals were read but no journal reached the session", () => { + it("answers no-declaration rather than throwing", () => { + expect(rowOf(record(), undefined)).toBe("no-declaration"); + }); +}); + +describe("taskRowOf, inferring from a session's sole written task", () => { + it("infers at the first moment the journal witnessed", () => { + const row = rowOf(record({ event_timestamp: "2026-08-18T10:00:00Z" }), journal()); + + expect(row).toStrictEqual({ task: TASK, attribution: "inferred" }); + }); + + it("infers at the last moment the journal witnessed", () => { + const row = rowOf(record({ event_timestamp: "2026-08-18T11:00:00Z" }), journal()); + + expect(row).toStrictEqual({ task: TASK, attribution: "inferred" }); + }); + + it("infers nothing one second after the last witnessed moment", () => { + expect(rowOf(record({ event_timestamp: "2026-08-18T11:00:01Z" }), journal())).toBe( + "no-declaration" + ); + }); + + it("infers nothing one second before the first witnessed moment", () => { + expect(rowOf(record({ event_timestamp: "2026-08-18T09:59:59Z" }), journal())).toBe( + "precedes-journal" + ); + }); + + it("infers nothing from a journal that witnessed no readable moment", () => { + expect(rowOf(record(), journal({ witnessed: undefined }))).toBe("no-declaration"); + }); + + it("infers nothing for a record whose own moment does not parse", () => { + expect(rowOf(record({ event_timestamp: "not a moment" }), journal())).toBe("no-declaration"); + }); +}); + +describe("taskRows", () => { + function group(overrides: Partial): TaskGroup { + return { totals: totalsOf(1), ...overrides }; + } + + it("drops a named group missing its attribution", () => { + expect(taskRows(new Map([["k", group({ task: TASK })]]))).toStrictEqual([]); + }); + + it("drops a named group missing its task", () => { + expect(taskRows(new Map([["k", group({ attribution: "declared" })]]))).toStrictEqual([]); + }); + + it("breaks a tie between two tasks on the task name", () => { + const rows = taskRows( + new Map([ + ["b", group({ task: "2026_08/b", attribution: "declared" })], + ["a", group({ task: "2026_08/a", attribution: "declared" })], + ]) + ); + + expect(rows.map((row) => row.task)).toStrictEqual(["2026_08/a", "2026_08/b"]); + }); + + it("breaks a tie between one task's two attributions on the attribution", () => { + const rows = taskRows( + new Map([ + ["i", group({ task: TASK, attribution: "inferred" })], + ["d", group({ task: TASK, attribution: "declared" })], + ]) + ); + + expect(rows.map((row) => row.attribution)).toStrictEqual(["declared", "inferred"]); + }); +}); + +describe("backlogRows", () => { + const noneDeclared = backlogKeyOf({ task: TASK, attribution: "declared" }, undefined); + const unreadable = backlogKeyOf( + { task: TASK, attribution: "declared" }, + new Map([[TASK, { kind: "unreadable" }]]) + ); + + it("names a declared item as its own row, never as a reason", () => { + expect(backlogRows(new Map([["ISSUE-7", totalsOf(1)]]))).toStrictEqual([ + { backlog: "ISSUE-7", totals: { requests: 1, costMicroUsd: 1_000_000 } }, + ]); + }); + + it("keeps the no-declaration and unreadable rows after the named ones", () => { + const rows = backlogRows( + new Map([ + [noneDeclared, totalsOf(3)], + [unreadable, totalsOf(2)], + ["ISSUE-7", totalsOf(1)], + ]) + ); + + expect(rows).toStrictEqual([ + { backlog: "ISSUE-7", totals: { requests: 1, costMicroUsd: 1_000_000 } }, + { declaration: "none", totals: { requests: 1, costMicroUsd: 3_000_000 } }, + { declaration: "unreadable", totals: { requests: 1, costMicroUsd: 2_000_000 } }, + ]); + }); + + it("orders two named items largest first", () => { + const rows = backlogRows( + new Map([ + ["ISSUE-1", totalsOf(1)], + ["ISSUE-2", totalsOf(2)], + ]) + ); + + expect(rows.map((row) => row.backlog)).toStrictEqual(["ISSUE-2", "ISSUE-1"]); + }); + + it("breaks a tie between two named items on the item", () => { + const rows = backlogRows( + new Map([ + ["ISSUE-2", totalsOf(1)], + ["ISSUE-1", totalsOf(1)], + ]) + ); + + expect(rows.map((row) => row.backlog)).toStrictEqual(["ISSUE-1", "ISSUE-2"]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/tool-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/tool-rows.unit.test.ts new file mode 100644 index 000000000..556dd8593 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/tool-rows.unit.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import type { CostReportToolDeclaration } from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; +import { buildToolRows } from "../../../../../../src/contexts/telemetry/domain/report/axes/tool-rows.js"; + +const COVERED: CostReportToolDeclaration = { + tool: "claude", + coverage: "covered", + capability: { + localRead: null, + export: null, + journalAttributable: false, + taskAttributable: false, + }, +}; + +describe("buildToolRows", () => { + it("carries no reason key at all for a tool that declared none", () => { + expect(buildToolRows([COVERED], new Map(), new Map())).toStrictEqual([ + { + tool: "claude", + coverage: "covered", + capability: COVERED.capability, + totals: { requests: 0 }, + }, + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/record-reconciliation.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/record-reconciliation.unit.test.ts new file mode 100644 index 000000000..450d3a5e7 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/record-reconciliation.unit.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { + collapseBilledRequests, + collapseSupersededTurns, +} from "../../../../../src/contexts/telemetry/domain/report/record-reconciliation.js"; +import type { TelemetrySinkRecord } from "../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +const BASE: TelemetrySinkRecord = { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", +}; + +function record(overrides: Partial): TelemetrySinkRecord { + return { ...BASE, ...overrides }; +} + +describe("collapseSupersededTurns", () => { + it("keeps the reading of a re-read turn that carries the largest counters", () => { + const partial = record({ turn_id: "t-1", input_tokens: 10, output_tokens: 1 }); + const complete = record({ turn_id: "t-1", input_tokens: 10, output_tokens: 90 }); + + expect(collapseSupersededTurns([partial, complete])).toStrictEqual([complete]); + }); + + it("leaves a record read once exactly as it arrived", () => { + const only = record({ turn_id: "t-1", input_tokens: 10 }); + + expect(collapseSupersededTurns([only])).toStrictEqual([only]); + }); + + it("never groups an export record on its turn id, which there names a prompt several calls share", () => { + const first = record({ provenance: "export", turn_id: "p-1", output_tokens: 1 }); + const second = record({ provenance: "export", turn_id: "p-1", output_tokens: 2 }); + + expect(collapseSupersededTurns([first, second])).toStrictEqual([first, second]); + }); +}); + +describe("collapseBilledRequests", () => { + const BILLED = "req_1"; + + it("keeps the record carrying the amount as the one a report sums", () => { + const priced = record({ billed_request_id: BILLED, cost_usd: 0.5, output_tokens: 9 }); + const unpriced = record({ provenance: "export", billed_request_id: BILLED, output_tokens: 9 }); + + expect(collapseBilledRequests([unpriced, priced])).toStrictEqual([priced]); + }); + + it("answers the same record whichever of two unpriced readings came first", () => { + const local = record({ billed_request_id: BILLED, output_tokens: 9 }); + const exported = record({ provenance: "export", billed_request_id: BILLED, output_tokens: 9 }); + + expect(collapseBilledRequests([local, exported])).toStrictEqual( + collapseBilledRequests([exported, local]) + ); + expect(collapseBilledRequests([local, exported])).toHaveLength(1); + }); + + it("leaves a session record beside a request record sharing its billed id, never merged into it", () => { + const request = record({ billed_request_id: BILLED, cost_usd: 0.5 }); + const session = record({ kind: "session", billed_request_id: BILLED, output_tokens: 9 }); + + expect(collapseBilledRequests([request, session])).toStrictEqual([session, request]); + }); + + it("borrows the step a sibling resolved from a journal interval when the survivor states none", () => { + const priced = record({ provenance: "export", billed_request_id: BILLED, cost_usd: 0.5 }); + const stepped = record({ + billed_request_id: BILLED, + step_attribution: "journal-interval", + step: "implement", + step_plugin: "aidd-dev", + }); + + expect(collapseBilledRequests([priced, stepped])).toStrictEqual([ + { + ...priced, + step_attribution: "journal-interval", + step: "implement", + step_plugin: "aidd-dev", + }, + ]); + }); + + it("prefers the tool-stated step over a journal-interval one when both siblings resolved one", () => { + const priced = record({ provenance: "export", billed_request_id: BILLED, cost_usd: 0.5 }); + const fromJournal = record({ + billed_request_id: BILLED, + step_attribution: "journal-interval", + step: "plan", + }); + const fromTool = record({ + billed_request_id: BILLED, + step_attribution: "tool-stated", + step: "implement", + }); + + expect(collapseBilledRequests([priced, fromJournal, fromTool])).toStrictEqual([ + { ...priced, step_attribution: "tool-stated", step: "implement", step_plugin: undefined }, + ]); + }); + + it("keeps the step the survivor stated itself rather than a sibling's", () => { + const priced = record({ + provenance: "export", + billed_request_id: BILLED, + cost_usd: 0.5, + step_attribution: "journal-interval", + step: "plan", + }); + const fromTool = record({ + billed_request_id: BILLED, + step_attribution: "tool-stated", + step: "implement", + }); + + expect(collapseBilledRequests([priced, fromTool])).toStrictEqual([priced]); + }); + + it("borrows the person, name included, from the sibling that carried one", () => { + const priced = record({ provenance: "export", billed_request_id: BILLED, cost_usd: 0.5 }); + const known = record({ + billed_request_id: BILLED, + person_id: "ada", + person_display_name: "Ada L.", + }); + + expect(collapseBilledRequests([priced, known])).toStrictEqual([ + { ...priced, person_id: "ada", person_display_name: "Ada L." }, + ]); + }); + + it("borrows a person without inventing a display name the sibling never carried", () => { + const priced = record({ provenance: "export", billed_request_id: BILLED, cost_usd: 0.5 }); + const known = record({ billed_request_id: BILLED, person_id: "ada" }); + + expect(collapseBilledRequests([priced, known])).toStrictEqual([ + { ...priced, person_id: "ada" }, + ]); + }); + + it("keeps the person the survivor carried itself rather than a sibling's", () => { + const priced = record({ + billed_request_id: BILLED, + cost_usd: 0.5, + person_id: "ada", + person_display_name: "Ada L.", + }); + const other = record({ + provenance: "export", + billed_request_id: BILLED, + person_id: "bob", + person_display_name: "Bob", + }); + + expect(collapseBilledRequests([priced, other])).toStrictEqual([priced]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/report-selection.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/report-selection.unit.test.ts new file mode 100644 index 000000000..336b75615 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/report-selection.unit.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; +import type { + CostReportInput, + CostReportSessionJournal, +} from "../../../../../src/contexts/telemetry/domain/cost-report.js"; +import { + activeFilters, + emptySelectionOf, + selectionStages, + taskMembership, +} from "../../../../../src/contexts/telemetry/domain/report/report-selection.js"; +import type { TelemetrySinkRecord } from "../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +const TASK = "2026_08/widgets"; +const OTHER_TASK = "2026_08/gadgets"; +const IN_TASK = { + startMs: Date.parse("2026-08-17T09:00:00Z"), + endMs: Date.parse("2026-08-17T10:00:00Z"), +}; + +function record(overrides: Partial): TelemetrySinkRecord { + return { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", + event_timestamp: "2026-08-17T09:30:00Z", + ...overrides, + }; +} + +function journal(overrides: Partial): CostReportSessionJournal { + return { + vendorId: "s-1", + tool: "claude", + writtenPaths: [], + taskIntervals: [], + flowIntervals: [], + ...overrides, + }; +} + +function input(overrides: Partial): CostReportInput { + return { + fromDay: "2026-08-17", + toDay: "2026-08-17", + records: [], + journals: [], + declaredTools: [], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + ...overrides, + }; +} + +describe("taskMembership", () => { + it("keeps, per session, only the declared intervals naming the task asked for", () => { + const forTask = { ...IN_TASK, path: `aidd_docs/tasks/${TASK}/plan.md` }; + const forOther = { ...IN_TASK, path: `aidd_docs/tasks/${OTHER_TASK}/plan.md` }; + + const membership = taskMembership([journal({ taskIntervals: [forOther, forTask] })], TASK); + + expect([...membership.declaredIntervalsByVendorId]).toStrictEqual([["s-1", [forTask]]]); + }); + + it("gives a session that never declared the task no entry at all, rather than an empty one", () => { + const forOther = { ...IN_TASK, path: `aidd_docs/tasks/${OTHER_TASK}/plan.md` }; + + const membership = taskMembership([journal({ taskIntervals: [forOther] })], TASK); + + expect([...membership.declaredIntervalsByVendorId]).toStrictEqual([]); + }); +}); + +describe("selectionStages", () => { + it("names the task stage after the task, carrying what the task kept", () => { + const inside = record({ vendor_id: "s-1" }); + const outside = record({ vendor_id: "s-2" }); + const membership = taskMembership( + [journal({ writtenPaths: [`aidd_docs/tasks/${TASK}/plan.md`] })], + TASK + ); + + const stages = selectionStages([inside, outside], input({ task: TASK }), membership); + + expect(stages).toStrictEqual([ + { name: undefined, value: undefined, records: [inside, outside] }, + { name: "task", value: TASK, records: [inside] }, + ]); + }); +}); + +describe("emptySelectionOf", () => { + it("answers nothing when every stage kept something", () => { + const kept = record({ model: "opus" }); + const stages = selectionStages([kept], input({ filters: { model: "opus" } }), null); + + expect(emptySelectionOf(stages, input({ filters: { model: "opus" } }), null)).toBeUndefined(); + }); + + it("knows a task that only a declared interval names", () => { + const declared = { ...IN_TASK, path: `aidd_docs/tasks/${TASK}/plan.md` }; + const membership = taskMembership([journal({ taskIntervals: [declared] })], TASK); + const outside = record({ event_timestamp: "2026-08-17T12:00:00Z" }); + const stages = selectionStages([outside], input({ task: TASK }), membership); + + expect(emptySelectionOf(stages, input({ task: TASK }), membership)).toStrictEqual({ + filter: "task", + value: TASK, + known: true, + }); + }); + + it("knows a task that only a written file names", () => { + const membership = taskMembership( + [journal({ vendorId: "s-9", writtenPaths: [`aidd_docs/tasks/${TASK}/plan.md`] })], + TASK + ); + const elsewhere = record({ vendor_id: "s-1" }); + const stages = selectionStages([elsewhere], input({ task: TASK }), membership); + + expect(emptySelectionOf(stages, input({ task: TASK }), membership)).toStrictEqual({ + filter: "task", + value: TASK, + known: true, + }); + }); + + it("reports a task no session ever declared or wrote into as one never known", () => { + const membership = taskMembership([journal({})], TASK); + const stages = selectionStages([record({})], input({ task: TASK }), membership); + + expect(emptySelectionOf(stages, input({ task: TASK }), membership)).toStrictEqual({ + filter: "task", + value: TASK, + known: false, + }); + }); + + it("knows a tool from the declared list, however many tools are declared beside it", () => { + const declaredTools: CostReportInput["declaredTools"] = [ + { + tool: "claude", + coverage: "covered", + capability: { + localRead: null, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + { + tool: "codex", + coverage: "covered", + capability: { + localRead: null, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + ]; + const asked = input({ declaredTools, filters: { tool: "codex" } }); + const stages = selectionStages([record({ tool: "claude" })], asked, null); + + expect(emptySelectionOf(stages, asked, null)).toStrictEqual({ + filter: "tool", + value: "codex", + known: true, + }); + }); + + it("reports a tool outside the declared list as one never known", () => { + const asked = input({ filters: { tool: "codex" } }); + const stages = selectionStages([record({ tool: "claude" })], asked, null); + + expect(emptySelectionOf(stages, asked, null)).toStrictEqual({ + filter: "tool", + value: "codex", + known: false, + }); + }); + + it("knows a model seen anywhere the caller looked, even outside this period", () => { + const asked = input({ + filters: { model: "opus" }, + knownValues: { projects: new Set(), steps: new Set(), models: new Set(["opus"]) }, + }); + const stages = selectionStages([record({ model: "haiku" })], asked, null); + + expect(emptySelectionOf(stages, asked, null)).toStrictEqual({ + filter: "model", + value: "opus", + known: true, + }); + }); + + it("blames the combination when the culprit's value matched the period before other filters ran", () => { + const asked = input({ + filters: { project: "acme", model: "opus" }, + knownValues: { projects: new Set(["acme"]), steps: new Set(), models: new Set(["opus"]) }, + }); + const acmeOnHaiku = record({ project_id: "acme", model: "haiku" }); + const otherOnOpus = record({ project_id: "other", model: "opus" }); + const stages = selectionStages([acmeOnHaiku, otherOnOpus], asked, null); + + expect(emptySelectionOf(stages, asked, null)).toStrictEqual({ + filter: "model", + value: "opus", + known: true, + combination: true, + }); + }); + + it("measures a combination against the task's own records, not the whole period, under --task", () => { + const membership = taskMembership( + [journal({ vendorId: "s-1", writtenPaths: [`aidd_docs/tasks/${TASK}/plan.md`] })], + TASK + ); + const asked = input({ + task: TASK, + filters: { model: "opus" }, + knownValues: { projects: new Set(), steps: new Set(), models: new Set(["opus"]) }, + }); + const inTaskOnHaiku = record({ vendor_id: "s-1", model: "haiku" }); + const outsideOnOpus = record({ vendor_id: "s-2", model: "opus" }); + const stages = selectionStages([inTaskOnHaiku, outsideOnOpus], asked, membership); + + expect(emptySelectionOf(stages, asked, membership)).toStrictEqual({ + filter: "model", + value: "opus", + known: true, + }); + }); + + it("never blames a combination on the task filter itself", () => { + const membership = taskMembership([journal({})], TASK); + const stages = selectionStages([record({})], input({ task: TASK }), membership); + + expect(emptySelectionOf(stages, input({ task: TASK }), membership)).not.toHaveProperty( + "combination" + ); + }); +}); + +describe("activeFilters", () => { + it("answers nothing for an empty filters object", () => { + expect(activeFilters({})).toBeUndefined(); + }); + + it("answers nothing when every filter is present but undefined", () => { + expect(activeFilters({ project: undefined, model: undefined })).toBeUndefined(); + }); + + it("keeps only the filters given, in the fixed order project, step, model, tool", () => { + expect(activeFilters({ tool: "claude", project: "acme" })).toStrictEqual({ + project: "acme", + tool: "claude", + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/row-ordering.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/row-ordering.unit.test.ts new file mode 100644 index 000000000..c512469d4 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/row-ordering.unit.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { CostTotals } from "../../../../../src/contexts/telemetry/domain/cost-report.js"; +import { + bySize, + isoSecondsFromMs, +} from "../../../../../src/contexts/telemetry/domain/report/row-ordering.js"; + +interface Row { + readonly key: string; + readonly totals: CostTotals; +} + +function ordered(rows: readonly Row[]): readonly string[] { + return bySize( + rows, + (row) => row.totals, + (row) => row.key + ).map((row) => row.key); +} + +describe("bySize", () => { + it("weighs a costless row by all four counters added together, the cache ones included", () => { + const rows: Row[] = [ + { key: "cache-heavy", totals: { requests: 1, cacheCreationTokens: 100 } }, + { key: "output-heavy", totals: { requests: 1, inputTokens: 5, outputTokens: 10 } }, + { key: "input-heavy", totals: { requests: 1, inputTokens: 10, outputTokens: 1 } }, + ]; + + expect(ordered(rows)).toEqual(["cache-heavy", "output-heavy", "input-heavy"]); + }); + + it("breaks a tie on the row's own key, so the same rows always come out in one order", () => { + const rows: Row[] = [ + { key: "b", totals: { requests: 1, inputTokens: 3 } }, + { key: "a", totals: { requests: 1, inputTokens: 3 } }, + ]; + + expect(ordered(rows)).toEqual(["a", "b"]); + expect(ordered([...rows].reverse())).toEqual(["a", "b"]); + }); + + it("leaves the rows it was given untouched", () => { + const rows: Row[] = [ + { key: "small", totals: { requests: 1, inputTokens: 1 } }, + { key: "large", totals: { requests: 1, inputTokens: 9 } }, + ]; + + bySize( + rows, + (row) => row.totals, + (row) => row.key + ); + + expect(rows.map((row) => row.key)).toEqual(["small", "large"]); + }); +}); + +describe("isoSecondsFromMs", () => { + it("renders a moment to the second, the way the journal spells a line's own moment", () => { + expect(isoSecondsFromMs(Date.parse("2026-08-17T09:04:05.000Z"))).toBe("2026-08-17T09:04:05Z"); + }); + + it("drops the milliseconds rather than rounding them into the next second", () => { + expect(isoSecondsFromMs(Date.parse("2026-08-17T09:04:05.999Z"))).toBe("2026-08-17T09:04:05Z"); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/session-anchor.unit.test.ts b/cli/tests/contexts/telemetry/domain/session-anchor.unit.test.ts new file mode 100644 index 000000000..2c20117ce --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/session-anchor.unit.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { resolveSessionAnchor } from "../../../../src/contexts/telemetry/domain/session-anchor.js"; + +describe("resolveSessionAnchor", () => { + it("prefers the Codex thread over the enclosing Claude Code session", () => { + expect( + resolveSessionAnchor({ CODEX_THREAD_ID: "thread-1", CLAUDE_CODE_SESSION_ID: "claude-1" }) + ).toBe("thread-1"); + }); + + it("falls back to the Claude Code session when no Codex thread is set", () => { + expect(resolveSessionAnchor({ CLAUDE_CODE_SESSION_ID: "claude-1" })).toBe("claude-1"); + }); + + it("answers nothing when neither host named a session", () => { + expect(resolveSessionAnchor({})).toBeUndefined(); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts b/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts index dbcb740ac..25cebeaa9 100644 --- a/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts @@ -3,7 +3,11 @@ import type { RunJournal, RunJournalSessionStart, } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; -import { resolveSessionProject } from "../../../../src/contexts/telemetry/domain/session-project.js"; +import { + anchorProjectElsewhere, + resolveSessionProject, + type SessionProject, +} from "../../../../src/contexts/telemetry/domain/session-project.js"; function sessionOf(overrides: Partial = {}): RunJournalSessionStart { return { @@ -57,4 +61,127 @@ describe("resolveSessionProject", () => { it("names no project for a session with no journal at all", () => { expect(resolveSessionProject(null)).toBeNull(); }); + + it("reads an empty remote as none, falling back to the directory-name field", () => { + const journal = journalOf(sessionOf({ project_id: "acme-widgets", project_remote: "" })); + + expect(resolveSessionProject(journal)).toStrictEqual({ + projectId: "acme-widgets", + projectField: "project_id", + }); + }); + + it("names no project when both fields are empty strings", () => { + expect( + resolveSessionProject(journalOf(sessionOf({ project_id: "", project_remote: "" }))) + ).toBeNull(); + }); +}); + +describe("anchorProjectElsewhere", () => { + const HERE_REMOTE = "git@github.com:acme/widgets.git"; + const here: readonly SessionProject[] = [ + { projectId: HERE_REMOTE, projectField: "project_remote" }, + ]; + const BOTH: readonly SessionProject[] = [ + ...here, + { projectId: "widgets", projectField: "project_id" }, + ]; + + it("names the project a stored record puts the anchored session under", () => { + expect( + anchorProjectElsewhere(here, [ + { project_id: "git@github.com:acme/other.git", project_field: "project_remote" }, + ]) + ).toBe("git@github.com:acme/other.git"); + }); + + it("names nothing when a stored record puts the anchor under this very project", () => { + expect( + anchorProjectElsewhere(here, [ + { project_id: "git@github.com:acme/widgets.git", project_field: "project_remote" }, + ]) + ).toBeNull(); + }); + + it("compares nothing across two different namings", () => { + expect( + anchorProjectElsewhere(here, [{ project_id: "widgets", project_field: "project_id" }]) + ).toBeNull(); + }); + + it("reads one project named by both fields as two values, never one match", () => { + expect( + anchorProjectElsewhere( + [...here, { projectId: "acme-other", projectField: "project_id" }], + [{ project_id: HERE_REMOTE, project_field: "project_id" }] + ) + ).toBe(HERE_REMOTE); + }); + + it("names nothing for a record whose field is no field this journal ever wrote", () => { + expect( + anchorProjectElsewhere(BOTH, [ + { project_id: "git@github.com:acme/other.git", project_field: "project_slug" }, + ]) + ).toBeNull(); + }); + + it("names nothing for a record whose field names a project the record itself does not", () => { + expect(anchorProjectElsewhere(BOTH, [{ project_field: "project_id" }])).toBeNull(); + }); + + it("names nothing for a record naming a project by no field at all", () => { + expect(anchorProjectElsewhere(BOTH, [{ project_id: "acme-other" }])).toBeNull(); + }); + + it("reads an empty project on a record as naming none", () => { + expect( + anchorProjectElsewhere(BOTH, [{ project_id: "", project_field: "project_id" }]) + ).toBeNull(); + }); + + it("names a project the records put the anchor under by directory name", () => { + expect( + anchorProjectElsewhere(BOTH, [{ project_id: "acme-other", project_field: "project_id" }]) + ).toBe("acme-other"); + }); + + it("names nothing when the stored records say nothing about a project", () => { + expect(anchorProjectElsewhere(here, [{}, { project_id: "" }])).toBeNull(); + }); + + it("names nothing when no record carries the field that named the project", () => { + expect( + anchorProjectElsewhere(here, [{ project_id: "git@github.com:acme/other.git" }]) + ).toBeNull(); + }); + + it("names nothing when nothing here names a project to compare against", () => { + expect( + anchorProjectElsewhere( + [], + [{ project_id: "git@github.com:acme/other.git", project_field: "project_remote" }] + ) + ).toBeNull(); + }); + + it("names every project the records disagree on, rather than picking one", () => { + expect( + anchorProjectElsewhere(here, [ + { project_id: "git@github.com:acme/other.git", project_field: "project_remote" }, + { project_id: "git@github.com:acme/third.git", project_field: "project_remote" }, + { project_id: "git@github.com:acme/other.git", project_field: "project_remote" }, + ]) + ).toBe("git@github.com:acme/other.git, git@github.com:acme/third.git"); + }); + + it("names nothing when one of several records puts the anchor here", () => { + expect( + anchorProjectElsewhere(here, [ + { project_id: "git@github.com:acme/other.git", project_field: "project_remote" }, + { project_id: "git@github.com:acme/widgets.git", project_field: "project_remote" }, + ]) + ).toBeNull(); + }); }); diff --git a/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts b/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts index ddb00142c..bf9d4beaf 100644 --- a/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts @@ -28,4 +28,8 @@ describe("namesTheSameSkill — one skill, two hosts, two spellings", () => { expect(namesTheSameSkill("artifact-design", "aidd-context:artifact-design")).toBe(true); expect(namesTheSameSkill("artifact-design", "aidd-context:artifact-diagramming")).toBe(false); }); + + it("drops a one-letter plugin prefix, whose colon sits at index one", () => { + expect(namesTheSameSkill("01-plan", "p:01-plan")).toBe(true); + }); }); diff --git a/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts index c367d26a3..2bc49c5af 100644 --- a/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts @@ -406,3 +406,70 @@ describe("buildStepIntervals — a step the session never closed", () => { }); }); }); + +describe("buildStepIntervals — an orchestration starting while a plain step runs", () => { + it("closes the plain step at the orchestrating step_start, not at the journal's end", () => { + const intervals = buildStepIntervals( + journalWith( + [ + A_START, + { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-orchestrator:01-sdlc" }, + ], + [{ type: "file_written", at: "2026-08-20T11:00:00Z", path: "aidd_docs/note.md" }] + ) + ); + + expect(intervals.find((interval) => interval.skill === A_START.skill)).toStrictEqual({ + skill: A_START.skill, + startMs: Date.parse(A_START.at), + endMs: Date.parse("2026-08-20T10:05:00Z"), + closedBy: "boundary", + }); + }); +}); + +describe("attributeMoment — two intervals opened at the same second", () => { + const SDLC_AT_TEN = { + type: "step_start", + at: "2026-08-20T10:00:00Z", + skill: "aidd-orchestrator:01-sdlc", + } as const; + const SPEC_AT_TEN = { + type: "step_start", + at: "2026-08-20T10:00:00Z", + skill: "aidd-pm:04-spec", + } as const; + const LATER_TURN_END = { type: "turn_end", at: "2026-08-20T11:00:00Z" } as const; + + it("answers the orchestration when it is the one that closes first", () => { + const intervals = buildStepIntervals( + journalOf( + SDLC_AT_TEN, + SPEC_AT_TEN, + { type: "step_end", at: "2026-08-20T10:20:00Z", skill: "aidd-orchestrator:01-sdlc" }, + LATER_TURN_END + ) + ); + + expect(attributeMoment(intervals, "2026-08-20T10:05:00Z")).toStrictEqual({ + source: "journal-interval", + step: "aidd-orchestrator:01-sdlc", + }); + }); + + it("answers the invoked step when it is the one that closes first", () => { + const intervals = buildStepIntervals( + journalOf( + SDLC_AT_TEN, + SPEC_AT_TEN, + { type: "step_end", at: "2026-08-20T10:10:00Z", skill: "aidd-pm:04-spec" }, + LATER_TURN_END + ) + ); + + expect(attributeMoment(intervals, "2026-08-20T10:05:00Z")).toStrictEqual({ + source: "journal-interval", + step: "aidd-pm:04-spec", + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts index 4fb731108..7bd264c3b 100644 --- a/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts @@ -288,4 +288,25 @@ describe("taskUnattributedReason — which of four distinct facts applies", () = expect(taskUnattributedReason(intervals, undefined)).toBe("journal-silent"); expect(taskUnattributedReason(intervals, "not-a-date")).toBe("journal-silent"); }); + + it("reads a record at the journal's very first witnessed moment as inside the journal, not before it", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + const journalFromMs = Date.parse("2026-08-17T09:30:00Z"); + + expect(taskUnattributedReason(intervals, "2026-08-17T09:30:00Z", journalFromMs)).toBe( + "precedes-declaration" + ); + }); + + it("names precedes-declaration while any declaration still lies ahead of the moment", () => { + const intervals = buildTaskIntervals(journalOf([WANTED, OTHER], [TURN_END])); + + expect(taskUnattributedReason(intervals, "2026-08-17T10:05:00Z")).toBe("precedes-declaration"); + }); + + it("reads a record at the very instant of the only declaration as not before it", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + + expect(taskUnattributedReason(intervals, WANTED.at)).toBe("journal-silent"); + }); }); diff --git a/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts b/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts index 9937ceeee..daac6affe 100644 --- a/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts @@ -119,3 +119,13 @@ describe("taskIdentitiesFromWrittenPaths", () => { } }); }); + +describe("taskIdentityFromWrittenPath — where a single-file task must start and end", () => { + it("names no task for a single file whose name only begins with .md", () => { + expect(taskIdentityFromWrittenPath("aidd_docs/tasks/2026_08/2026_08_21_x.md.bak")).toBeNull(); + }); + + it("names no task for a single-file task path nested under another directory", () => { + expect(taskIdentityFromWrittenPath("docs/aidd_docs/tasks/2026_08/2026_08_21_x.md")).toBeNull(); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts new file mode 100644 index 000000000..52bd5cc38 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts @@ -0,0 +1,454 @@ +import { describe, expect, it } from "vitest"; +import { + diagnoseTelemetryClaims, + type TelemetryClaim, + type TelemetryClaimId, + type TelemetryClaimJournal, + type TelemetryClaimToolRead, + type TelemetryEvidence, +} from "../../../../src/contexts/telemetry/domain/telemetry-claim.js"; + +const RUNS_DIR_LABEL = "aidd_docs/runs"; +const CODEX_CONFIG = "/home/.codex/config.toml"; + +function journal(overrides: Partial = {}): TelemetryClaimJournal { + return { vendorId: undefined, sessionStartAt: undefined, turnClosed: false, ...overrides }; +} + +function toolRead(overrides: Partial = {}): TelemetryClaimToolRead { + return { tool: "claude", sessionFound: false, hasIntervals: false, records: [], ...overrides }; +} + +function evidence(overrides: Partial = {}): TelemetryEvidence { + return { + journals: [], + toolReads: [], + runsDirLabel: RUNS_DIR_LABEL, + recorderDeclared: false, + recorderDeclarationReadable: true, + foreignSchemaVersions: [], + ...overrides, + }; +} + +function claimOf(overrides: Partial, id: TelemetryClaimId): TelemetryClaim { + const found = diagnoseTelemetryClaims(evidence(overrides)).find((c) => c.claim === id); + if (found === undefined) throw new Error(`no ${id} claim`); + return found; +} + +describe("the hook-fired claim, word for word", () => { + it("names the newest session_start across every anchored run file, whatever order they came in", () => { + const hookFired = claimOf( + { + journals: [ + journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" }), + journal({ vendorId: "s-2" }), + journal({ vendorId: "s-3", sessionStartAt: "2026-07-01T09:00:00Z" }), + ], + currentSessionId: "s-1", + }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "ok", + reason: "session-anchored", + detail: "3 run file(s), most recent session_start 2026-08-20T09:00:00Z", + }); + }); + + it("says the session_start was unreadable when no anchored run file carries one", () => { + const hookFired = claimOf( + { journals: [journal({ vendorId: "s-1" })], currentSessionId: "s-1" }, + "hook-fired" + ); + + expect(hookFired.detail).toBe( + "1 run file(s), most recent session_start an unreadable session_start" + ); + }); + + it("anchors this session on its own run file even when an older session's file sits beside it", () => { + const hookFired = claimOf( + { + journals: [ + journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" }), + journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" }), + ], + currentSessionId: "s-1", + }, + "hook-fired" + ); + + expect(hookFired.reason).toBe("session-anchored"); + }); + + it("spells out the Codex trust fault, its config path and both ways out", () => { + const hookFired = claimOf( + { + currentSessionId: "codex-1", + hookTrust: { readable: true, trusted: false, configPath: CODEX_CONFIG }, + }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "fail", + reason: "untrusted-codex-hook", + detail: + "Codex has not trusted this plugin's hook — no trusted_hash for hooks/hooks.json:session_start " + + "in /home/.codex/config.toml. Approve it interactively once, or pass " + + "--dangerously-bypass-hook-trust to codex exec for a headless run.", + }); + }); + + it("names this session as having left no run file once its Codex hook is trusted", () => { + const hookFired = claimOf( + { + journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], + currentSessionId: "codex-current", + hookTrust: { readable: true, trusted: true, configPath: CODEX_CONFIG }, + }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "fail", + reason: "session-left-no-run-file", + detail: "this session left no run file — the newest one is from 2026-07-01T09:00:00Z", + }); + }); + + it("adds nothing about trust to the never-fired fault when there is no trust gate", () => { + const hookFired = claimOf({ currentSessionId: "s-1" }, "hook-fired"); + + expect(hookFired.detail).toBe( + "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + + "recorder is declared nowhere this build checks" + ); + }); + + it("adds nothing about trust to the never-fired fault when the trust state is readable", () => { + const hookFired = claimOf( + { + currentSessionId: "codex-1", + hookTrust: { readable: true, trusted: true, configPath: CODEX_CONFIG }, + }, + "hook-fired" + ); + + expect(hookFired.detail).toBe( + "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + + "recorder is declared nowhere this build checks" + ); + }); + + it("appends the unreadable trust state, with its reason, to the never-fired fault", () => { + const hookFired = claimOf( + { + currentSessionId: "codex-1", + hookTrust: { readable: false, reason: "ENOENT" }, + }, + "hook-fired" + ); + + expect(hookFired.detail).toBe( + "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + + "recorder is declared nowhere this build checks — Codex's own hook trust state could " + + "not be read either (ENOENT), so this may be the same cause" + ); + }); + + it("tells a damaged declaring file apart from one that never declared the recorder", () => { + const hookFired = claimOf( + { currentSessionId: "s-1", recorderDeclared: false, recorderDeclarationReadable: false }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "unknown", + reason: "recorder-declaration-unreadable", + detail: + "no run file in aidd_docs/runs yet, and whether the recorder is declared could not be " + + "read — see recorder declared, above, for which location. A damaged declaring file is " + + "not the same absence as one that never declared the recorder.", + }); + }); + + it("counts the anchorless run files and names the two causes, never a hook that did not fire", () => { + const hookFired = claimOf( + { currentSessionId: "s-1", journals: [journal(), journal()] }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "fail", + reason: "anchorless-run-file", + detail: + "2 run file(s) in aidd_docs/runs, but none carry a readable session_start to anchor " + + "them — a torn write, or a hooks block that registers another event without " + + "SessionStart, never a hook that has not fired", + }); + }); + + it("lists the foreign schema versions once each, ascending, beside the count of files", () => { + const hookFired = claimOf( + { currentSessionId: "s-1", foreignSchemaVersions: [3, 2, 3, 1] }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "fail", + reason: "journal-in-another-schema", + detail: + "4 run file(s) in aidd_docs/runs written under a schema this build does not read " + + "(1, 2, 3) — a journal from another version of the plugin, never a hook that did not fire", + }); + }); + + it("spells out whose project the anchored session is, word for word", () => { + const hookFired = claimOf( + { + journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-08-20T09:00:00Z" })], + currentSessionId: "s-elsewhere", + anchorInAnotherProject: "git@github.com:acme/other.git", + }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "unknown", + reason: "anchor-in-another-project", + detail: + "this session belongs to git@github.com:acme/other.git, not to this project — its " + + "stored figures name that project, and nothing here is evidence about its hook. The " + + "newest run file here is from 2026-08-20T09:00:00Z", + }); + }); + + it("spells out the missing anchor beside the newest session_start", () => { + const hookFired = claimOf( + { journals: [journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" })] }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "unknown", + reason: "no-session-anchor", + detail: + "1 run file(s), most recent session_start 2026-08-20T09:00:00Z — no session anchor " + + "available to tell whether this session's hook fired", + }); + }); +}); + +describe("the session-journalled claim, word for word", () => { + it("has nothing to read without a run file", () => { + expect(claimOf({}, "session-journalled")).toStrictEqual({ + claim: "session-journalled", + verdict: "unknown", + reason: "no-run-file-to-read", + detail: "no run file to read", + }); + }); + + it("counts the run files that carry only session_start", () => { + expect( + claimOf( + { journals: [journal({ vendorId: "s-1" }), journal({ vendorId: "s-2" })] }, + "session-journalled" + ) + ).toStrictEqual({ + claim: "session-journalled", + verdict: "fail", + reason: "only-session-start", + detail: "2 run file(s), all carrying only session_start — nothing closed the turn", + }); + }); + + it("counts the closed turns against the anchored run files", () => { + expect( + claimOf( + { + journals: [ + journal({ vendorId: "s-1", turnClosed: true }), + journal({ vendorId: "s-2" }), + journal({ vendorId: "s-3", turnClosed: true }), + ], + }, + "session-journalled" + ) + ).toStrictEqual({ + claim: "session-journalled", + verdict: "ok", + reason: "turn-closed", + detail: "2 of 3 run file(s) carry more than session_start", + }); + }); +}); + +describe("the tool-files-readable claim, word for word", () => { + it("has no session to look for when the journal names none", () => { + expect(claimOf({}, "tool-files-readable")).toStrictEqual({ + claim: "tool-files-readable", + verdict: "unknown", + reason: "no-session-named", + detail: "no session named by the journal", + }); + }); + + it("names every covered tool once and every journalled session when none was found", () => { + expect( + claimOf( + { + journals: [journal({ vendorId: "s-1" }), journal({ vendorId: "s-2" })], + toolReads: [ + toolRead({ tool: "claude" }), + toolRead({ tool: "codex" }), + toolRead({ tool: "claude" }), + ], + }, + "tool-files-readable" + ) + ).toStrictEqual({ + claim: "tool-files-readable", + verdict: "fail", + reason: "no-session-found-for-any-tool", + detail: + "no session found for any journalled session, across every covered tool (claude, codex) " + + "— while the journal names s-1, s-2", + }); + }); + + it("appends the failed attempts, quoting the last error, when none was found", () => { + expect( + claimOf( + { + journals: [journal({ vendorId: "s-1" })], + toolReads: [ + toolRead({ tool: "claude", error: "EACCES" }), + toolRead({ tool: "codex", error: "ENOENT" }), + ], + }, + "tool-files-readable" + ).detail + ).toBe( + "no session found for any journalled session, across every covered tool (claude, codex) " + + "— while the journal names s-1 — 2 read attempt(s) failed: ENOENT" + ); + }); + + it("tallies each tool's reads, joined by a semicolon, with no failure suffix when none failed", () => { + expect( + claimOf( + { + journals: [journal({ vendorId: "s-1" })], + toolReads: [ + toolRead({ tool: "claude", sessionFound: true }), + toolRead({ tool: "claude", sessionFound: false }), + toolRead({ tool: "codex", sessionFound: false }), + ], + }, + "tool-files-readable" + ) + ).toStrictEqual({ + claim: "tool-files-readable", + verdict: "ok", + reason: "session-found", + detail: "claude: 1 of 2 session(s) read; codex: 0 of 1 session(s) read", + }); + }); + + it("counts the reads a tool could not make beside the ones it made", () => { + expect( + claimOf( + { + journals: [journal({ vendorId: "s-1" })], + toolReads: [ + toolRead({ tool: "claude", sessionFound: true }), + toolRead({ tool: "claude", error: "EACCES" }), + toolRead({ tool: "claude", error: "ENOENT" }), + ], + }, + "tool-files-readable" + ).detail + ).toBe("claude: 1 of 3 session(s) read, 2 could not be read"); + }); +}); + +describe("the records-join claim, word for word", () => { + it("has nothing to join without a record", () => { + expect(claimOf({ toolReads: [toolRead()] }, "records-join")).toStrictEqual({ + claim: "records-join", + verdict: "unknown", + reason: "no-record-to-join", + detail: "no record read to join", + }); + }); + + it("has no join material without an interval or a tool-stated step", () => { + expect( + claimOf( + { toolReads: [toolRead({ records: [{ stepAttribution: "unattributed" }] })] }, + "records-join" + ) + ).toStrictEqual({ + claim: "records-join", + verdict: "unknown", + reason: "no-join-material", + detail: "no step interval and no tool-stated step — see session journalled", + }); + }); + + it("takes one read's intervals as join material for every read's records", () => { + expect( + claimOf( + { + toolReads: [ + toolRead({ tool: "claude", hasIntervals: true }), + toolRead({ tool: "codex", records: [{ stepAttribution: "unattributed" }] }), + ], + }, + "records-join" + ) + ).toStrictEqual({ + claim: "records-join", + verdict: "fail", + reason: "all-unattributed", + detail: "1 record(s) found, joined: 0 — every record unattributed", + }); + }); + + it("takes a single tool-stated record as join material, with no interval at all", () => { + expect( + claimOf( + { + toolReads: [ + toolRead({ + records: [ + { stepAttribution: "unattributed" }, + { stepAttribution: "tool-stated" }, + { stepAttribution: "unattributed" }, + ], + }), + ], + }, + "records-join" + ) + ).toStrictEqual({ + claim: "records-join", + verdict: "ok", + reason: "records-joined", + detail: "1 of 3 record(s) joined a step, 2 unattributed", + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts index 6f25f54bb..8f3c48dbb 100644 --- a/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts @@ -145,6 +145,44 @@ describe("diagnoseTelemetryClaims — hook fired", () => { expect(hookFired?.detail).toContain("2026-07-01T09:00:00Z"); }); + it("names the other project the anchored session belongs to, rather than failing", () => { + const result = diagnoseTelemetryClaims( + evidence({ + journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], + currentSessionId: "s-elsewhere", + anchorInAnotherProject: "git@github.com:acme/other.git", + }) + ); + const hookFired = claim(result, "hook-fired"); + expect(hookFired?.verdict).toBe("unknown"); + expect(hookFired?.reason).toBe("anchor-in-another-project"); + expect(hookFired?.detail).toContain("git@github.com:acme/other.git"); + }); + + it("still fails a session left unproven when no other project claims the anchor", () => { + const result = diagnoseTelemetryClaims( + evidence({ + journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], + currentSessionId: "s-current", + }) + ); + + expect(claim(result, "hook-fired")?.reason).toBe("session-left-no-run-file"); + }); + + it("prefers the other project over an untrusted Codex hook", () => { + const result = diagnoseTelemetryClaims( + evidence({ + journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], + currentSessionId: "codex-elsewhere", + anchorInAnotherProject: "git@github.com:acme/other.git", + hookTrust: { readable: true, trusted: false, configPath: "/home/.codex/config.toml" }, + }) + ); + + expect(claim(result, "hook-fired")?.reason).toBe("anchor-in-another-project"); + }); + it("cannot tell whether this session's hook fired without an anchor", () => { const result = diagnoseTelemetryClaims( evidence({ journals: [journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" })] }) diff --git a/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts index a12aee834..488a0fb62 100644 --- a/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts @@ -36,4 +36,8 @@ describe("findLeftoverExportKeys", () => { it("reads an env block that is not an object as nothing found", () => { expect(findLeftoverExportKeys(JSON.stringify({ env: "not-an-object" }))).toEqual([]); }); + + it("reads a file whose whole content is a JSON array as nothing found, rather than throwing", () => { + expect(findLeftoverExportKeys("[1, 2]")).toEqual([]); + }); }); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-sink-record.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-sink-record.unit.test.ts index 9d4356893..4ff7d2bc1 100644 --- a/cli/tests/contexts/telemetry/domain/telemetry-sink-record.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-sink-record.unit.test.ts @@ -93,16 +93,30 @@ describe("telemetrySinkRecordDayKey()", () => { step_attribution: "unattributed", }; - it("answers the UTC day for a real moment, the fast path and the parsed one alike", () => { + it("answers the UTC day for a real moment, whatever its offset", () => { expect(telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "2026-08-18T01:00:00Z" })).toBe( "2026-08-18" ); - // No `Z` offset - the parsed path, not the sliced one. expect( telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "2026-08-18T01:00:00+05:00" }) ).toBe("2026-08-17"); }); + it("rolls 24:00 over to the next day, as the moment it names does", () => { + expect(telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "2026-09-10T24:00:00Z" })).toBe( + "2026-09-11" + ); + }); + + it("answers a calendar day for a parseable moment not written in ISO form", () => { + expect( + telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "Thu, 10 Sep 2026 23:00:00 Z" }) + ).toBe("2026-09-10"); + expect(telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "+002026-09-10T10:00:00Z" })).toBe( + "2026-09-10" + ); + }); + it("answers undefined for no moment at all", () => { expect(telemetrySinkRecordDayKey({ ...BASE })).toBeUndefined(); }); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts index 3af967d4c..7c950c465 100644 --- a/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts @@ -188,3 +188,13 @@ describe("buildTelemetrySwitchFile", () => { }); }); }); + +describe("parseTelemetrySwitchFile — an endpoint that is not a string", () => { + it("reads no endpoint from a number, rather than carrying the number", () => { + const config = parseTelemetrySwitchFile( + JSON.stringify({ telemetry: { enabled: true, endpoint: 42 } }) + ); + + expect(config).toStrictEqual({ enabled: true, endpoint: undefined }); + }); +}); diff --git a/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts index 1d8ce1536..0e7776ebb 100644 --- a/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts @@ -54,6 +54,36 @@ describe("reading whether Codex has been told it may run the recorder's hook", ( expect(trust).toMatchObject({ readable: true, trusted: false }); }); + it("reads a trusted_hash line without the key above it as not trusted", async () => { + codexHome('trusted_hash = "abc123"\n'); + + expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: false }); + }); + + it("reads the key wherever it sits in the file, not only on its first line", async () => { + codexHome(`[projects]\n[other]\n${APPROVED_KEY}\ntrusted_hash = "abc123"\n`); + + expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: true }); + }); + + it("reads a trusted_hash written without spaces around its equals sign as trusted", async () => { + codexHome(`${APPROVED_KEY}\ntrusted_hash="abc123"\n`); + + expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: true }); + }); + + it("reads an indented trusted_hash line as trusted", async () => { + codexHome(`${APPROVED_KEY}\n trusted_hash = "abc123"\n`); + + expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: true }); + }); + + it("reads a key whose name merely ends in trusted_hash as not trusted", async () => { + codexHome(`${APPROVED_KEY}\nnot_trusted_hash = "abc123"\n`); + + expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: false }); + }); + it("reads a config naming no hook of ours as not trusted", async () => { codexHome( '[hooks.state."someone-else@1.0.0:hooks/hooks.json:session_start:0:0"]\ntrusted_hash = "x"\n' diff --git a/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts index ced459539..f5312daf0 100644 --- a/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts @@ -156,6 +156,51 @@ describe("OpencodeCostReaderAdapter", () => { } ); + it.skipIf(skipOnWindows)( + "names the spawn failure itself, not an exit code, when the command exceeds its timeout", + async () => { + const env = installStandIn(SLOW_SCRIPT); + restorePath = env.restore; + + await expect(new OpencodeCostReaderAdapter(200).read(SESSION_ID)).rejects.toThrow( + /^opencode export ses_test_read failed: spawnSync opencode ETIMEDOUT$/ + ); + } + ); + + it.skipIf(skipOnWindows)( + "names the exit code and the trimmed stderr on a generic failure", + async () => { + const env = installStandIn(GENERIC_FAILURE_SCRIPT); + restorePath = env.restore; + + await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( + /^opencode export ses_test_read exited with code 2: internal error: storage unavailable$/ + ); + } + ); + + it.skipIf(skipOnWindows)("says so when a failing command wrote nothing to stderr", async () => { + const env = installStandIn("#!/bin/sh\nexit 3\n"); + restorePath = env.restore; + + await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( + /^opencode export ses_test_read exited with code 3: no stderr output$/ + ); + }); + + it.skipIf(skipOnWindows)( + "reads a command killed by a signal as an unknown exit code", + async () => { + const env = installStandIn("#!/bin/sh\nkill -KILL $$\n"); + restorePath = env.restore; + + await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( + /^opencode export ses_test_read exited with code unknown: no stderr output$/ + ); + } + ); + it.skipIf(skipOnWindows)( "throws OpencodeExportError when the command answers with something that is not JSON", async () => { @@ -167,4 +212,32 @@ describe("OpencodeCostReaderAdapter", () => { ); } ); + + it.skipIf(skipOnWindows)( + "names the parse failure when the command answers with something that is not JSON", + async () => { + const env = installStandIn('#!/bin/sh\necho "not json"\nexit 0\n'); + restorePath = env.restore; + + await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( + /^opencode export ses_test_read did not answer with JSON: Unexpected token/ + ); + } + ); + + it.skipIf(skipOnWindows)( + "finds the binary in a later PATH entry when the first holds nothing", + async () => { + const env = installStandIn(WELL_BEHAVED_SCRIPT); + restorePath = env.restore; + const binDir = process.env.PATH ?? ""; + const emptyDir = mkdtempSync(join(tmpdir(), "aidd-opencode-first-")); + process.env.PATH = `${emptyDir}:${binDir}`; + + const { sessionFound } = await new OpencodeCostReaderAdapter().read(SESSION_ID); + + rmSync(emptyDir, { recursive: true, force: true }); + expect(sessionFound).toBe(true); + } + ); }); diff --git a/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts index c2ac367b3..6c88de722 100644 --- a/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts @@ -1,22 +1,36 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { PersonIdentityAdapter } from "../../../../src/contexts/telemetry/infrastructure/person-identity-adapter.js"; +import { IdentityWriteError, UnreadableIdentityFileError } from "../../../../src/kernel/errors.js"; + +/** Windows reads `%APPDATA%`, never `HOME`: a sandbox that moved `HOME` alone wrote the real profile. */ +/** POSIX errno: Windows answers ENOENT where a path runs through a file. */ +const POSIX_ERRNO_ONLY = process.platform === "win32"; + +function relocateProfile(home: string): void { + process.env.HOME = home; + process.env.APPDATA = join(home, ".config"); +} /** On real disk: every write here goes through the file and is read back through it, since * what this adapter stores is what decides whose records are whose. */ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is handed", () => { let previousHome: string | undefined; + let previousAppData: string | undefined; const homes: string[] = []; beforeEach(() => { previousHome = process.env.HOME; + previousAppData = process.env.APPDATA; }); afterEach(async () => { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; + if (previousAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = previousAppData; for (const home of homes.splice(0)) await rm(home, { recursive: true, force: true }); }); @@ -28,7 +42,7 @@ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is it("removes the identity file it was constructed against", async () => { const home = await freshHome(); - process.env.HOME = home; + relocateProfile(home); const adapter = new PersonIdentityAdapter(); await adapter.mint(); @@ -40,7 +54,7 @@ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is it("is a no-op, not a failure, when the path is already gone", async () => { const home = await freshHome(); - process.env.HOME = home; + relocateProfile(home); const adapter = new PersonIdentityAdapter(); await expect(adapter.forget(adapter.filePath)).resolves.toBe(false); @@ -50,7 +64,7 @@ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is // relocation between the preview and the removal cannot redirect it. it("acts on the path it is handed, immune to HOME being relocated afterwards", async () => { const realHome = await freshHome(); - process.env.HOME = realHome; + relocateProfile(realHome); const adapter = new PersonIdentityAdapter(); await adapter.mint(); const shownPath = adapter.filePath; // what a preview would have shown @@ -60,7 +74,7 @@ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is const victimPath = join(elsewhereHome, ".config", "aidd", "identity.json"); await writeFile(victimPath, '{"person_id":"victim"}\n'); - process.env.HOME = elsewhereHome; // relocated AFTER the path was shown + relocateProfile(elsewhereHome); // relocated AFTER the path was shown await adapter.forget(shownPath); @@ -71,15 +85,19 @@ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is describe("PersonIdentityAdapter — what it writes, and what it reads back", () => { let previousHome: string | undefined; + let previousAppData: string | undefined; const homes: string[] = []; beforeEach(() => { previousHome = process.env.HOME; + previousAppData = process.env.APPDATA; }); afterEach(async () => { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; + if (previousAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = previousAppData; for (const home of homes.splice(0)) await rm(home, { recursive: true, force: true }); }); @@ -88,7 +106,7 @@ describe("PersonIdentityAdapter — what it writes, and what it reads back", () async function adapterInFreshHome(): Promise { const home = await mkdtemp(join(tmpdir(), "aidd-identity-rw-")); homes.push(home); - process.env.HOME = home; + relocateProfile(home); await mkdir(join(home, ".config", "aidd"), { recursive: true }); return new PersonIdentityAdapter(); } @@ -182,4 +200,169 @@ describe("PersonIdentityAdapter — what it writes, and what it reads back", () expect(JSON.parse(raw)).toMatchObject({ person_id: minted.personId, origin: "minted" }); expect(raw.endsWith("\n")).toBe(true); }); + + it("writes the quietest shape: no also_me key until an identifier is added", async () => { + const adapter = await adapterInFreshHome(); + const minted = await adapter.mint(); + + expect(JSON.parse(await readFile(adapter.filePath, "utf8"))).toStrictEqual({ + person_id: minted.personId, + origin: "minted", + }); + }); + + it.skipIf(process.platform === "win32")( + "writes a file readable by this person alone", + async () => { + const adapter = await adapterInFreshHome(); + + await adapter.mint(); + + expect(((await stat(adapter.filePath)).mode & 0o777).toString(8)).toBe("600"); + } + ); + + it("reads an empty person_id as nobody having chosen", async () => { + const adapter = await adapterInFreshHome(); + await writeFile(adapter.filePath, '{"person_id":""}\n'); + + expect(await adapter.read()).toBeNull(); + expect(await adapter.readStrict()).toBeNull(); + }); + + it("reads a person_id that is not a string as nobody having chosen", async () => { + const adapter = await adapterInFreshHome(); + await writeFile(adapter.filePath, '{"person_id":42}\n'); + + expect(await adapter.readStrict()).toBeNull(); + }); + + it("keeps only the strings among the identifiers added onto a person", async () => { + const adapter = await adapterInFreshHome(); + await writeFile(adapter.filePath, '{"person_id":"p-1","also_me":["machine-2",3,null]}\n'); + + expect(await adapter.readStrict()).toStrictEqual({ + personId: "p-1", + origin: "minted", + alsoMe: ["machine-2"], + }); + }); + + it("reads an empty display name as none at all", async () => { + const adapter = await adapterInFreshHome(); + await writeFile(adapter.filePath, '{"person_id":"p-1","display_name":""}\n'); + + expect(await adapter.readStrict()).toStrictEqual({ + personId: "p-1", + origin: "minted", + alsoMe: [], + }); + }); + + it("reads a display name that is not a string as none at all", async () => { + const adapter = await adapterInFreshHome(); + await writeFile(adapter.filePath, '{"person_id":"p-1","display_name":7}\n'); + + expect(await adapter.readStrict()).toStrictEqual({ + personId: "p-1", + origin: "minted", + alsoMe: [], + }); + }); + + it("says what it was asked to add onto when no identity exists", async () => { + const adapter = await adapterInFreshHome(); + + await expect(adapter.addAlsoMe("machine-2")).rejects.toThrow( + `Could not write the identity file at ${adapter.filePath} (no identity exists to add an identifier onto).` + ); + }); + + it("says what it was asked to remove from when no identity exists", async () => { + const adapter = await adapterInFreshHome(); + + await expect(adapter.removeAlsoMe("machine-2")).rejects.toThrow( + `Could not write the identity file at ${adapter.filePath} (no identity exists to remove an identifier onto).` + ); + }); + + it("refuses strictly a file that is there but cannot be read as a file", async () => { + const adapter = await adapterInFreshHome(); + await mkdir(adapter.filePath); + + const strict = adapter.readStrict(); + + await expect(strict).rejects.toBeInstanceOf(UnreadableIdentityFileError); + await expect(strict).rejects.toThrow( + `Could not read the identity file at ${adapter.filePath} (EISDIR` + ); + }); + it.skipIf(POSIX_ERRNO_ONLY)( + "reports a write that could not go out, naming the file", + async () => { + const home = await mkdtemp(join(tmpdir(), "aidd-identity-rw-")); + homes.push(home); + relocateProfile(home); + await writeFile(join(home, ".config"), ""); + const adapter = new PersonIdentityAdapter(); + + const minted = adapter.mint(); + + await expect(minted).rejects.toBeInstanceOf(IdentityWriteError); + await expect(minted).rejects.toThrow( + `Could not write the identity file at ${adapter.filePath} (ENOTDIR` + ); + } + ); +}); + +describe("PersonIdentityAdapter.forget — what it removes and what it reports", () => { + let previousHome: string | undefined; + let previousAppData: string | undefined; + const homes: string[] = []; + + beforeEach(() => { + previousHome = process.env.HOME; + previousAppData = process.env.APPDATA; + }); + + afterEach(async () => { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = previousAppData; + for (const home of homes.splice(0)) await rm(home, { recursive: true, force: true }); + }); + + async function adapterInFreshHome(): Promise { + const home = await mkdtemp(join(tmpdir(), "aidd-identity-forget-")); + homes.push(home); + relocateProfile(home); + await mkdir(join(home, ".config", "aidd"), { recursive: true }); + return new PersonIdentityAdapter(); + } + + it("removes a damaged identity that is a directory, not a file", async () => { + const adapter = await adapterInFreshHome(); + await mkdir(adapter.filePath); + await writeFile(join(adapter.filePath, "stray"), ""); + + expect(await adapter.forget(adapter.filePath)).toBe(true); + await expect(readFile(adapter.filePath, "utf8")).rejects.toThrow(); + }); + it.skipIf(POSIX_ERRNO_ONLY)( + "reports a removal that failed for a reason other than being gone, as a removal", + async () => { + const adapter = await adapterInFreshHome(); + await adapter.mint(); + const unreachable = join(adapter.filePath, "child"); + + const forgotten = adapter.forget(unreachable); + + await expect(forgotten).rejects.toBeInstanceOf(IdentityWriteError); + await expect(forgotten).rejects.toThrow( + `Could not remove the identity file at ${unreachable} (ENOTDIR` + ); + } + ); }); diff --git a/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts index 02dd3ed67..b8e7af05c 100644 --- a/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts @@ -53,6 +53,58 @@ describe("RunJournalReaderAdapter", () => { ]); }); + it("reads an empty worktree id and repo id as not stated, never as a worktree named nothing", async () => { + await writeFile( + join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), + runFileLines({ + type: "session_start", + at: "2026-08-20T09:59:00Z", + run_id: RUN_ID, + tool: "claude-code", + vendor_id: SESSION_ID, + worktree_id: "", + worktree_repo_id: "", + }) + ); + + const journal = await new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); + + expect(journal?.session).toStrictEqual({ + type: "session_start", + at: "2026-08-20T09:59:00Z", + run_id: RUN_ID, + tool: "claude-code", + vendor_id: SESSION_ID, + }); + }); + + it("keeps a stated worktree id and repo id as written", async () => { + await writeFile( + join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), + runFileLines({ + type: "session_start", + at: "2026-08-20T09:59:00Z", + run_id: RUN_ID, + tool: "claude-code", + vendor_id: SESSION_ID, + worktree_id: "feature-x", + worktree_repo_id: "framework", + }) + ); + + const journal = await new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); + + expect(journal?.session).toStrictEqual({ + type: "session_start", + at: "2026-08-20T09:59:00Z", + run_id: RUN_ID, + tool: "claude-code", + vendor_id: SESSION_ID, + worktree_id: "feature-x", + worktree_repo_id: "framework", + }); + }); + it("answers null for a session no run file names, rather than the wrong file", async () => { await writeFile( join(runsDir, `${RUN_ID}__other-session.jsonl`), diff --git a/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-lines.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-lines.integration.test.ts new file mode 100644 index 000000000..f56eac3c1 --- /dev/null +++ b/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-lines.integration.test.ts @@ -0,0 +1,312 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + READABLE_JOURNAL_SCHEMA_VERSION, + RunJournalReaderAdapter, +} from "../../../../src/contexts/telemetry/infrastructure/run-journal-reader-adapter.js"; + +const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; +const SESSION_ID = "22222222-2222-4222-8222-222222222222"; +const AT = "2026-08-20T10:00:00Z"; + +const HEADER = { + type: "session_start", + at: "2026-08-20T09:59:00Z", + run_id: RUN_ID, + tool: "claude-code", + vendor_id: SESSION_ID, +} as const; + +function lines(...values: readonly unknown[]): string { + return `${values.map((value) => JSON.stringify(value)).join("\n")}\n`; +} + +describe("RunJournalReaderAdapter, one line at a time", () => { + let projectRoot: string; + let runsDir: string; + + beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), "aidd-run-journal-lines-")); + runsDir = join(projectRoot, "aidd_docs", "runs"); + await mkdir(runsDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + }); + + async function readAfterWriting(...values: readonly unknown[]) { + await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), lines(...values)); + return new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); + } + + async function readRaw(content: string) { + await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), content); + return new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); + } + + describe("the run file a session id names", () => { + it("ignores a file whose name carries the session id under another extension", async () => { + await writeFile( + join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonx`), + lines({ type: "step_start", at: AT, skill: "x" }) + ); + + await expect(new RunJournalReaderAdapter(projectRoot).read(SESSION_ID)).resolves.toBeNull(); + }); + + it("ignores a run file naming no session at all", async () => { + await writeFile(join(runsDir, `${RUN_ID}__.jsonl`), lines({ type: "turn_end", at: AT })); + + await expect(new RunJournalReaderAdapter(projectRoot).read(SESSION_ID)).resolves.toBeNull(); + }); + + it("ignores a file whose run id and session id are not joined by the double underscore", async () => { + await writeFile( + join(runsDir, `${RUN_ID}--${SESSION_ID}.jsonl`), + lines({ type: "turn_end", at: AT }) + ); + + await expect(new RunJournalReaderAdapter(projectRoot).read(SESSION_ID)).resolves.toBeNull(); + }); + }); + + describe("a boundary line", () => { + it("reads a step_end as the boundary that names its skill", async () => { + const journal = await readAfterWriting({ + type: "step_end", + at: AT, + skill: "aidd-dev:01-plan", + }); + + expect(journal?.boundaries).toStrictEqual([ + { type: "step_end", at: AT, skill: "aidd-dev:01-plan" }, + ]); + }); + + it("carries a step_start's turn_id when the hook wrote one", async () => { + const journal = await readAfterWriting({ + type: "step_start", + at: AT, + skill: "aidd-dev:01-plan", + turn_id: "turn-7", + }); + + expect(journal?.boundaries).toStrictEqual([ + { type: "step_start", at: AT, skill: "aidd-dev:01-plan", turn_id: "turn-7" }, + ]); + }); + + it("carries no turn_id key at all for a step_start written without one", async () => { + const journal = await readAfterWriting({ + type: "step_start", + at: AT, + skill: "aidd-dev:01-plan", + }); + + expect(journal?.boundaries).toStrictEqual([ + { type: "step_start", at: AT, skill: "aidd-dev:01-plan" }, + ]); + }); + + it("drops a turn_end that carries no moment", async () => { + const journal = await readAfterWriting({ type: "turn_end" }); + + expect(journal?.boundaries).toStrictEqual([]); + }); + + it("drops a step_start that names no skill", async () => { + const journal = await readAfterWriting({ type: "step_start", at: AT }); + + expect(journal?.boundaries).toStrictEqual([]); + }); + + it("drops a moment that is not a string", async () => { + const journal = await readAfterWriting({ type: "turn_end", at: 1755684000000 }); + + expect(journal?.boundaries).toStrictEqual([]); + }); + + it("reads a line of a type it has never heard of as nothing, even carrying a moment and a skill", async () => { + const journal = await readAfterWriting({ + type: "step_paused", + at: AT, + skill: "aidd-dev:01-plan", + }); + + expect(journal).toStrictEqual({ boundaries: [], filesWritten: [], taskDeclarations: [] }); + }); + }); + + describe("a file_written or task_declared line", () => { + it.each([ + ["file_written", { type: "file_written", path: "aidd_docs/tasks/2026_08/t/plan.md" }], + ["file_written", { type: "file_written", at: AT }], + ["task_declared", { type: "task_declared", path: "aidd_docs/tasks/2026_08/t/plan.md" }], + ["task_declared", { type: "task_declared", at: AT }], + ])("drops a %s line missing its moment or its path: %j", async (_type, line) => { + const journal = await readAfterWriting(line); + + expect(journal).toStrictEqual({ boundaries: [], filesWritten: [], taskDeclarations: [] }); + }); + + it("reads a task_declared line as exactly its moment and its path", async () => { + const journal = await readAfterWriting({ + type: "task_declared", + at: AT, + path: "aidd_docs/tasks/2026_08/t/spec.md", + source: "stated", + }); + + expect(journal?.taskDeclarations).toStrictEqual([ + { type: "task_declared", at: AT, path: "aidd_docs/tasks/2026_08/t/spec.md" }, + ]); + }); + }); + + describe("the header line", () => { + it("reads a minimal header as exactly its five fields, adding no key for a field nobody wrote", async () => { + const journal = await readAfterWriting(HEADER); + + expect(journal?.session).toStrictEqual(HEADER); + }); + + it.each(["at", "run_id", "tool", "vendor_id"])( + "refuses a header missing %s alone", + async (field) => { + const partial = Object.fromEntries(Object.entries(HEADER).filter(([key]) => key !== field)); + const journal = await readAfterWriting(partial); + + expect(journal?.session).toBeUndefined(); + } + ); + + it("refuses a header whose run_id is not a string", async () => { + const journal = await readAfterWriting({ ...HEADER, run_id: 42 }); + + expect(journal?.session).toBeUndefined(); + }); + + it("takes the header only from a session_start line", async () => { + const journal = await readAfterWriting({ ...HEADER, type: "session_end" }); + + expect(journal?.session).toBeUndefined(); + }); + + it("carries both worktree fields when the hook wrote them", async () => { + const journal = await readAfterWriting({ + ...HEADER, + worktree_id: "feature-x", + worktree_repo_id: "acme/widgets", + }); + + expect(journal?.session).toStrictEqual({ + ...HEADER, + worktree_id: "feature-x", + worktree_repo_id: "acme/widgets", + }); + }); + + it("carries project_id, project_remote and schema_version each on its own", async () => { + const journal = await readAfterWriting({ + ...HEADER, + schema_version: READABLE_JOURNAL_SCHEMA_VERSION, + project_id: "widgets", + project_remote: "github.com/acme/widgets", + }); + + expect(journal?.session).toStrictEqual({ + ...HEADER, + schema_version: READABLE_JOURNAL_SCHEMA_VERSION, + project_id: "widgets", + project_remote: "github.com/acme/widgets", + }); + }); + + it("reads a schema_version that is not a number as none stated, and still reads the journal", async () => { + const journal = await readAfterWriting( + { ...HEADER, schema_version: String(READABLE_JOURNAL_SCHEMA_VERSION) }, + { type: "turn_end", at: AT } + ); + + expect(journal).toStrictEqual({ + boundaries: [{ type: "turn_end", at: AT }], + filesWritten: [], + taskDeclarations: [], + session: HEADER, + }); + }); + + it("reads a schema_version that overflowed to a non-finite number as none stated", async () => { + const journal = await readRaw( + `${JSON.stringify(HEADER).slice(0, -1)},"schema_version":1e999}\n` + ); + + expect(journal?.session).toStrictEqual(HEADER); + }); + }); + + describe("the run files it lists", () => { + it("lists only .jsonl names, sorted", async () => { + await writeFile(join(runsDir, "README.md"), "not a run file\n"); + await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), lines(HEADER)); + + await expect(new RunJournalReaderAdapter(projectRoot).listRunFiles()).resolves.toStrictEqual([ + `${RUN_ID}__${SESSION_ID}.jsonl`, + ]); + }); + + it("lists no run file at all, rather than throwing, when the runs directory is missing", async () => { + await rm(runsDir, { recursive: true, force: true }); + + await expect(new RunJournalReaderAdapter(projectRoot).listRunFiles()).resolves.toStrictEqual( + [] + ); + }); + + it("names no foreign schema for a journal under its own schema, nor for one stating none", async () => { + await writeFile( + join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), + lines({ ...HEADER, schema_version: READABLE_JOURNAL_SCHEMA_VERSION }) + ); + await writeFile(join(runsDir, `${RUN_ID}__other.jsonl`), lines(HEADER)); + + await expect( + new RunJournalReaderAdapter(projectRoot).listForeignSchemas() + ).resolves.toStrictEqual([]); + }); + + it("still names a foreign schema beside a run file whose header is torn", async () => { + await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), '{"type":"session_st\n'); + await writeFile( + join(runsDir, `${RUN_ID}__other.jsonl`), + lines({ ...HEADER, schema_version: READABLE_JOURNAL_SCHEMA_VERSION + 1 }) + ); + + await expect( + new RunJournalReaderAdapter(projectRoot).listForeignSchemas() + ).resolves.toStrictEqual([READABLE_JOURNAL_SCHEMA_VERSION + 1]); + }); + + it("skips a directory that merely carries the run file extension", async () => { + await mkdir(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`)); + const adapter = new RunJournalReaderAdapter(projectRoot); + + await expect(adapter.listForeignSchemas()).resolves.toStrictEqual([]); + await expect(adapter.list()).resolves.toStrictEqual([]); + await expect(adapter.read(SESSION_ID)).resolves.toBeNull(); + }); + }); + + describe("deleteRunFile", () => { + it("names the refused name and the directory in the error it throws", async () => { + const adapter = new RunJournalReaderAdapter(projectRoot); + + await expect(adapter.deleteRunFile(runsDir, "../escape.jsonl")).rejects.toThrow( + `refusing to delete "../escape.jsonl" — not a run file name inside ${runsDir}` + ); + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts index 28107c4bb..e61f7f3d8 100644 --- a/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts @@ -149,6 +149,71 @@ describe("TaskBacklogAdapter — reads a declaration without ever writing one", await expect(adapter.read(TASK_FOLDER)).resolves.toEqual({ kind: "unreadable" }); }); + it("answers unreadable for valid JSON missing written_at alone", async () => { + const root = await freshProject(); + await writeLink( + root, + JSON.stringify({ backlog: "ai-driven-dev/framework#617", written_by: "aidd-pm:04-spec" }) + ); + + await expect(new TaskBacklogAdapter(root).read(TASK_FOLDER)).resolves.toStrictEqual({ + kind: "unreadable", + }); + }); + + it("answers unreadable for valid JSON missing written_by alone", async () => { + const root = await freshProject(); + await writeLink( + root, + JSON.stringify({ backlog: "ai-driven-dev/framework#617", written_at: "2026-08-21T09:00:00Z" }) + ); + + await expect(new TaskBacklogAdapter(root).read(TASK_FOLDER)).resolves.toStrictEqual({ + kind: "unreadable", + }); + }); + + it("answers unreadable for an empty backlog reference", async () => { + const root = await freshProject(); + await writeLink( + root, + JSON.stringify({ + backlog: "", + written_at: "2026-08-21T09:00:00Z", + written_by: "aidd-pm:04-spec", + }) + ); + + await expect(new TaskBacklogAdapter(root).read(TASK_FOLDER)).resolves.toStrictEqual({ + kind: "unreadable", + }); + }); + + it("answers unreadable for a backlog reference that is not a string", async () => { + const root = await freshProject(); + await writeLink( + root, + JSON.stringify({ + backlog: 617, + written_at: "2026-08-21T09:00:00Z", + written_by: "aidd-pm:04-spec", + }) + ); + + await expect(new TaskBacklogAdapter(root).read(TASK_FOLDER)).resolves.toStrictEqual({ + kind: "unreadable", + }); + }); + + it("answers unreadable when the file is there but cannot be read, distinct from none", async () => { + const root = await freshProject(); + await mkdir(join(root, "aidd_docs", "tasks", "t", "backlog-link.json"), { recursive: true }); + + await expect(new TaskBacklogAdapter(root).read("aidd_docs/tasks/t/")).resolves.toStrictEqual({ + kind: "unreadable", + }); + }); + it("answers unreadable for a declaration missing its provenance", async () => { const root = await freshProject(); await writeLink(root, JSON.stringify({ backlog: "ai-driven-dev/framework#617" })); diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts index 128dfe1f7..2a94dbdd0 100644 --- a/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts @@ -9,6 +9,7 @@ import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { TelemetryEvidenceAdapter } from "../../../../src/contexts/telemetry/infrastructure/telemetry-evidence-adapter.js"; /** @@ -93,6 +94,276 @@ describe("what the switch setup reports, beside the answer itself", () => { expect(setup.readable).toBe(true); expect(setup.enabled).toBe(false); }); + + it("reads a project that turned it on as readable and enabled, naming the file", async () => { + const root = project(); + writeJson(join(root, ".aidd", "config.json"), { telemetry: { enabled: true } }); + + expect(await adapter().readSwitchSetup(root)).toStrictEqual({ + path: join(root, ".aidd", "config.json"), + enabled: true, + readable: true, + }); + }); + + it("reads a switch path that is a directory as unreadable, not as absent", async () => { + const root = project(); + mkdirSync(join(root, ".aidd", "config.json"), { recursive: true }); + + expect(await adapter().readSwitchSetup(root)).toStrictEqual({ + path: join(root, ".aidd", "config.json"), + enabled: false, + readable: false, + }); + }); +}); + +describe("where the recorder declaration is looked for", () => { + it("checks the manifest, both enabledPlugins files, the three Claude hook scopes and Cursor's hooks file, once each", async () => { + const root = project(); + const home = process.env.HOME ?? ""; + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration).toStrictEqual({ + declared: false, + declaredAt: [], + locationsChecked: [ + join(root, ".aidd", "manifest.json"), + join(root, ".claude", "settings.json"), + join(root, ".github", "copilot", "settings.json"), + join(root, ".claude", "settings.local.json"), + join(home, ".claude", "settings.json"), + join(root, ".cursor", "hooks.json"), + ], + unreadable: [], + }); + }); +}); + +describe("what the manifest says about the recorder", () => { + async function declarationFor(manifest: unknown) { + const root = project(); + writeJson(join(root, ".aidd", "manifest.json"), manifest); + const declaration = await adapter().readRecorderDeclaration(root); + return { declaration, manifestFile: join(root, ".aidd", "manifest.json") }; + } + + it("names the manifest alone as the declaring location", async () => { + const { declaration, manifestFile } = await declarationFor({ + tools: { claude: { plugins: [{ name: "aidd-telemetry", version: "1.0.0" }] } }, + }); + + expect(declaration.declaredAt).toStrictEqual([manifestFile]); + expect(declaration.unreadable).toStrictEqual([]); + }); + + it("finds the recorder under a second tool when the first declares other plugins only", async () => { + const { declaration, manifestFile } = await declarationFor({ + tools: { + codex: { plugins: [{ name: "aidd-dev", version: "1.0.0" }] }, + claude: { + plugins: [ + { name: "aidd-dev", version: "1.0.0" }, + { name: "aidd-telemetry", version: "1.0.0" }, + ], + }, + }, + }); + + expect(declaration.declaredAt).toStrictEqual([manifestFile]); + }); + + it("reads a manifest declaring only other plugins as not declaring the recorder", async () => { + const { declaration } = await declarationFor({ + tools: { claude: { plugins: [{ name: "aidd-dev", version: "1.0.0" }] } }, + }); + + expect(declaration.declared).toBe(false); + expect(declaration.unreadable).toStrictEqual([]); + }); + + it.each([ + ["a JSON null", null], + ["no tools key", {}], + ["a tool entry that is not an object", { tools: { claude: "installed" } }], + ["a plugins value that is not a list", { tools: { claude: { plugins: "aidd-telemetry" } } }], + [ + "a plugin entry that is not an object", + { tools: { claude: { plugins: ["aidd-telemetry"] } } }, + ], + ])("reads a manifest holding %s as not declaring, and not as unreadable", async (_, manifest) => { + const { declaration } = await declarationFor(manifest); + + expect(declaration.declared).toBe(false); + expect(declaration.declaredAt).toStrictEqual([]); + expect(declaration.unreadable).toStrictEqual([]); + }); + + it("names a damaged manifest as the one unreadable location", async () => { + const root = project(); + write(join(root, ".aidd", "manifest.json"), "{ tools: "); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.unreadable).toStrictEqual([join(root, ".aidd", "manifest.json")]); + expect(declaration.declaredAt).toStrictEqual([]); + }); + + it("names a manifest path that is a directory as unreadable, never as absent", async () => { + const root = project(); + mkdirSync(join(root, ".aidd", "manifest.json"), { recursive: true }); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.unreadable).toStrictEqual([join(root, ".aidd", "manifest.json")]); + }); +}); + +describe("what a tool's enabledPlugins says about the recorder", () => { + it("names Copilot's settings file alone when only it enables the recorder", async () => { + const root = project(); + writeJson(join(root, ".github", "copilot", "settings.json"), { + enabledPlugins: { "aidd-telemetry@aidd-framework": true }, + }); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.declaredAt).toStrictEqual([ + join(root, ".github", "copilot", "settings.json"), + ]); + expect(declaration.unreadable).toStrictEqual([]); + }); + + it("finds the recorder among other enabled plugins", async () => { + const root = project(); + writeJson(join(root, ".claude", "settings.json"), { + enabledPlugins: { "aidd-dev@aidd-framework": true, "aidd-telemetry@aidd-framework": true }, + }); + + expect((await adapter().readRecorderDeclaration(root)).declaredAt).toStrictEqual([ + join(root, ".claude", "settings.json"), + ]); + }); + + it("reads a key enabling some other plugin as not declaring the recorder", async () => { + const root = project(); + writeJson(join(root, ".claude", "settings.json"), { + enabledPlugins: { "aidd-dev@aidd-framework": true }, + }); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.declared).toBe(false); + expect(declaration.unreadable).toStrictEqual([]); + }); + + it.each([ + ["a JSON null", null], + ["no enabledPlugins key", {}], + ])( + "reads a settings file holding %s as not declaring, and not as unreadable", + async (_, settings) => { + const root = project(); + writeJson(join(root, ".claude", "settings.json"), settings); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.declaredAt).toStrictEqual([]); + expect(declaration.unreadable).toStrictEqual([]); + } + ); + + it("names a damaged settings file as the one unreadable location", async () => { + const root = project(); + write(join(root, ".github", "copilot", "settings.json"), "{ enabledPlugins: "); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.unreadable).toStrictEqual([ + join(root, ".github", "copilot", "settings.json"), + ]); + expect(declaration.declaredAt).toStrictEqual([]); + }); +}); + +describe("what a hooks block says about the recorder", () => { + it("names the user-scope Claude settings file when its hooks block invokes the entry point", async () => { + const root = project(); + const homeSettings = join(process.env.HOME ?? "", ".claude", "settings.json"); + writeJson(homeSettings, { + hooks: { + SessionStart: [ + { + hooks: [{ type: "command", command: "node .claude/hooks/aidd-telemetry/journal.cjs" }], + }, + ], + }, + }); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.declaredAt).toStrictEqual([homeSettings]); + expect(declaration.unreadable).toStrictEqual([]); + }); + + it("names Cursor's hooks file when it invokes the entry point from the recorder's own script dir", async () => { + const root = project(); + writeJson(join(root, ".cursor", "hooks.json"), { + version: 1, + hooks: { + sessionStart: [ + { command: "node ./other/journal.cjs" }, + { command: "node .cursor/hooks/aidd-telemetry/journal.cjs" }, + ], + }, + }); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.declaredAt).toStrictEqual([join(root, ".cursor", "hooks.json")]); + }); + + it("refuses the recorder's own script name under a directory that is not its hooks dir", async () => { + const root = project(); + writeJson(join(root, ".cursor", "hooks.json"), { + version: 1, + hooks: { sessionStart: [{ command: "node ./vendor/aidd-telemetry/journal.cjs" }] }, + }); + + expect((await adapter().readRecorderDeclaration(root)).declared).toBe(false); + }); + + it("refuses another script under the plugin token, however its hooks dir is named", async () => { + const root = project(); + writeJson(join(root, ".claude", "settings.local.json"), { + hooks: { + SessionStart: [ + { + hooks: [ + { + type: "command", + // biome-ignore lint/suspicious/noTemplateCurlyInString: Claude Code resolves this placeholder, the settings file carries it verbatim + command: "node ${CLAUDE_PLUGIN_ROOT}/hooks/other.cjs", + }, + ], + }, + ], + }, + }); + + expect((await adapter().readRecorderDeclaration(root)).declared).toBe(false); + }); + + it("names a damaged hooks file as the one unreadable location", async () => { + const root = project(); + write(join(root, ".cursor", "hooks.json"), "{ version: 1, "); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.unreadable).toStrictEqual([join(root, ".cursor", "hooks.json")]); + expect(declaration.declaredAt).toStrictEqual([]); + }); }); describe("whether anything is declared to do the recording", () => { @@ -196,6 +467,38 @@ describe("a payload that matched no known host", () => { expect(await adapter().readUnrecognisedPayload(root)).toBeNull(); }); + it("answers nothing for another record kind even when it carries a moment", async () => { + const root = project(); + write( + join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), + `${JSON.stringify({ type: "something-else", at: "2026-03-02T08:00:00Z" })}\n` + ); + + expect(await adapter().readUnrecognisedPayload(root)).toBeNull(); + }); + + it("answers nothing when the moment is not a string", async () => { + const root = project(); + write( + join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), + `${JSON.stringify({ type: "unrecognised_payload", at: 1772438400 })}\n` + ); + + expect(await adapter().readUnrecognisedPayload(root)).toBeNull(); + }); + + it("skips blank lines before the first record", async () => { + const root = project(); + write( + join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), + `\n \n${JSON.stringify({ type: "unrecognised_payload", at: "2026-03-02T08:00:00Z" })}\n` + ); + + expect(await adapter().readUnrecognisedPayload(root)).toStrictEqual({ + at: "2026-03-02T08:00:00Z", + }); + }); + // The hook writing this file anchors at the repository root, never at the directory a // session started from, so a reader must walk up rather than join onto `projectRoot`. it("finds the file from a subdirectory of the repository, not only from its root", async () => { diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts index bc1266786..90e8be685 100644 --- a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts @@ -1,4 +1,4 @@ -import { appendFile, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { appendFile, chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -114,6 +114,52 @@ describe("TelemetrySinkAdapter", () => { expect(records).toHaveLength(1); }); + it.skipIf(process.platform === "win32")( + "writes a day file readable by this person alone", + async () => { + const adapter = new TelemetrySinkAdapter(userConfigDir); + await adapter.ensureWritable(); + + const { filePath } = await adapter.appendRecord(RECORD, new Date("2026-08-17T10:00:00Z")); + + expect(((await stat(filePath)).mode & 0o777).toString(8)).toBe("600"); + } + ); + + it("lists day files only, leaving any other entry of the directory out", async () => { + const adapter = new TelemetrySinkAdapter(userConfigDir); + await adapter.ensureWritable(); + await adapter.appendRecord(RECORD, new Date("2026-08-17T10:00:00Z")); + await writeFile(join(adapter.rootDir, "notes.txt"), ""); + await writeFile(join(adapter.rootDir, "2026-08-16.jsonl.bak"), ""); + + expect(await adapter.listDayFiles()).toStrictEqual(["2026-08-17.jsonl"]); + }); + + it("lists nothing, rather than failing, before the directory exists", async () => { + const adapter = new TelemetrySinkAdapter(userConfigDir); + + expect(await adapter.listDayFiles()).toStrictEqual([]); + expect(await adapter.readRecordsForVendor("s-1")).toStrictEqual([]); + }); + + it("names the file and the directory it refused to delete outside of", async () => { + const adapter = new TelemetrySinkAdapter(userConfigDir); + + await expect(adapter.deleteDayFile(adapter.rootDir, "../VICTIM.txt")).rejects.toThrow( + `refusing to delete "../VICTIM.txt" — not a day file name inside ${adapter.rootDir}` + ); + }); + + it("deletes a day file that is already gone without complaint", async () => { + const adapter = new TelemetrySinkAdapter(userConfigDir); + await adapter.ensureWritable(); + + await expect( + adapter.deleteDayFile(adapter.rootDir, "2026-08-01.jsonl") + ).resolves.toBeUndefined(); + }); + // chmod blocks no write for root or behind Windows ACLs, where this would pass without // testing anything. it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( @@ -263,6 +309,62 @@ describe("TelemetrySinkAdapter.readRecordsInPeriod", () => { expect(backwards).toEqual(forwards); }); + it("collects every project, step and model any record names, whatever its period", async () => { + await adapter.appendRecord( + { + ...RECORD, + vendor_id: "full", + event_timestamp: "2026-08-17T10:00:00.000Z", + project_id: "p-1", + step: "aidd-dev:01-plan", + model: "claude-opus-5", + }, + STORED_ON + ); + await adapter.appendRecord( + { + ...RECORD, + vendor_id: "outside", + event_timestamp: "2026-07-01T10:00:00.000Z", + project_id: "p-2", + step: "aidd-dev:02-implement", + model: "gpt-5", + }, + STORED_ON + ); + await append("bare", "2026-08-17"); + + const read = await period("2026-08-17", "2026-08-17"); + + expect(read.records.map((record) => record.vendor_id)).toStrictEqual(["full", "bare"]); + expect(read.knownValues).toStrictEqual({ + projects: new Set(["p-1", "p-2"]), + steps: new Set(["aidd-dev:01-plan", "aidd-dev:02-implement"]), + models: new Set(["claude-opus-5", "gpt-5"]), + }); + }); + + it("tolerates a day file that cannot be read, counting nothing for it", async () => { + await append("whole", "2026-08-17"); + await mkdir(join(adapter.rootDir, "2026-08-22.jsonl")); + + const read = await period("2026-08-17", "2026-08-17"); + + expect(read.records.map((record) => record.vendor_id)).toStrictEqual(["whole"]); + expect(read.undated).toStrictEqual([]); + expect(read.skippedLines).toBe(0); + }); + + it("counts a line of nothing but whitespace as blank, not as skipped", async () => { + await append("whole", "2026-08-17"); + await appendFile(join(adapter.rootDir, "2026-08-21.jsonl"), " \n\t\n"); + + const read = await period("2026-08-17", "2026-08-17"); + + expect(read.records.map((record) => record.vendor_id)).toStrictEqual(["whole"]); + expect(read.skippedLines).toBe(0); + }); + it("answers an empty period with no records and nothing skipped, never an error", async () => { expect(await period("2026-08-17", "2026-08-18")).toEqual({ records: [], diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts index b457fd632..cb25500d0 100644 --- a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts @@ -1,4 +1,12 @@ -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -68,6 +76,76 @@ describe("where the figures land by default", () => { expect(withPlatform("win32", defaultConfigDir)).toBe(join(home, ".config", "aidd")); }); + it("a Windows machine that already journalled under .config keeps landing there", () => { + const home = freshHome(); + process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); + mkdirSync(join(home, ".config", "aidd", "telemetry"), { recursive: true }); + writeFileSync(join(home, ".config", "aidd", "telemetry", "notes.txt"), ""); + writeFileSync(join(home, ".config", "aidd", "telemetry", "2026-08-17.jsonl"), ""); + + expect(withPlatform("win32", defaultConfigDir)).toBe(join(home, ".config", "aidd")); + }); + + it("a Windows machine whose .config holds no day file is a fresh one", () => { + const home = freshHome(); + process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); + mkdirSync(join(home, ".config", "aidd", "telemetry"), { recursive: true }); + writeFileSync(join(home, ".config", "aidd", "telemetry", "notes.jsonl.txt"), ""); + + expect(withPlatform("win32", defaultConfigDir)).toBe(join(process.env.APPDATA, "aidd")); + }); + + it("a POSIX machine ignores APPDATA even when it is set", () => { + const home = freshHome(); + process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); + + expect(withPlatform("linux", defaultConfigDir)).toBe(join(home, ".config", "aidd")); + }); +}); + +describe("which variable located the figures", () => { + const previousTelemetryDir = process.env.AIDD_TELEMETRY_DIR; + const previousUserConfigDir = process.env.AIDD_USER_CONFIG_DIR; + + afterEach(() => { + for (const [key, value] of [ + ["AIDD_TELEMETRY_DIR", previousTelemetryDir], + ["AIDD_USER_CONFIG_DIR", previousUserConfigDir], + ] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it("is the figures' own name when AIDD_TELEMETRY_DIR is set, whatever else is", () => { + process.env.AIDD_TELEMETRY_DIR = join(tmpdir(), "figures"); + process.env.AIDD_USER_CONFIG_DIR = join(tmpdir(), "older"); + + expect(new TelemetrySinkAdapter().locatedBy).toBe("telemetry-dir"); + }); + + it("is the older config variable when only that one is set", () => { + delete process.env.AIDD_TELEMETRY_DIR; + process.env.AIDD_USER_CONFIG_DIR = join(tmpdir(), "older"); + + expect(new TelemetrySinkAdapter().locatedBy).toBe("user-config-dir"); + }); + + it("is the older config variable when the constructor names the directory", () => { + delete process.env.AIDD_TELEMETRY_DIR; + delete process.env.AIDD_USER_CONFIG_DIR; + + expect(new TelemetrySinkAdapter(join(tmpdir(), "older")).locatedBy).toBe("user-config-dir"); + }); + + it("is the default when nothing names a location", () => { + freshHome(); + delete process.env.AIDD_TELEMETRY_DIR; + delete process.env.AIDD_USER_CONFIG_DIR; + + expect(new TelemetrySinkAdapter().locatedBy).toBe("default"); + }); + it("the plugin README states the exact default the code writes", () => { // Forward slashes rather than `join`, which yields `~\\.config\\aidd` on Windows: the // prose reads the same on every platform, only the code follows the host's separator. diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-windows-acl.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-windows-acl.integration.test.ts new file mode 100644 index 000000000..f5e53fe8a --- /dev/null +++ b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-windows-acl.integration.test.ts @@ -0,0 +1,134 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import { TelemetrySinkAdapter } from "../../../../src/contexts/telemetry/infrastructure/telemetry-sink-adapter.js"; + +const RECORD: TelemetrySinkRecord = { + sink_schema_version: 2, + kind: "request", + provenance: "export", + tool: "claude", + vendor_id: "s-1", + vendor_field: "session.id", + cost_usd: 1, + step_attribution: "unattributed", +}; + +const ENV_KEYS = [ + "PATH", + "HOME", + "APPDATA", + "USERDOMAIN", + "USERNAME", + "ICACLS_LOG", + "AIDD_TELEMETRY_DIR", + "AIDD_USER_CONFIG_DIR", +] as const; + +const DAY = new Date("2026-08-17T10:00:00Z"); + +describe.skipIf(process.platform === "win32")( + "a default location on Windows is restricted to this account through icacls", + () => { + const previousEnv = new Map(); + const previousPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + let sandbox: string; + let logPath: string; + + beforeEach(() => { + for (const key of ENV_KEYS) previousEnv.set(key, process.env[key]); + sandbox = mkdtempSync(join(tmpdir(), "aidd-sink-acl-")); + const bin = join(sandbox, "bin"); + mkdirSync(bin); + logPath = join(sandbox, "icacls.log"); + writeFileSync(join(bin, "icacls"), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$ICACLS_LOG"\n'); + chmodSync(join(bin, "icacls"), 0o755); + process.env.PATH = `${bin}:${process.env.PATH ?? ""}`; + process.env.ICACLS_LOG = logPath; + process.env.HOME = join(sandbox, "home"); + process.env.APPDATA = join(sandbox, "appdata"); + process.env.USERDOMAIN = "ACME"; + process.env.USERNAME = "ada"; + delete process.env.AIDD_TELEMETRY_DIR; + delete process.env.AIDD_USER_CONFIG_DIR; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + }); + + afterEach(() => { + if (previousPlatform) Object.defineProperty(process, "platform", previousPlatform); + for (const key of ENV_KEYS) { + const value = previousEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(sandbox, { recursive: true, force: true }); + }); + + function calls(): string[] { + try { + return readFileSync(logPath, "utf8").trim().split("\n"); + } catch { + return []; + } + } + + it("grants the directory to DOMAIN\\user alone, recursively, on ensureWritable", async () => { + const sink = new TelemetrySinkAdapter(); + + await sink.ensureWritable(); + + expect(sink.rootDir).toBe(join(sandbox, "appdata", "aidd", "telemetry")); + expect(calls()).toStrictEqual([ + `${sink.rootDir} /inheritance:r /grant:r ACME\\ada:(OI)(CI)F /T /C /Q`, + ]); + }); + + it("grants a day file to this user alone, once, on the append that creates it", async () => { + const sink = new TelemetrySinkAdapter(); + + const { filePath } = await sink.appendRecord(RECORD, DAY); + await sink.appendRecord(RECORD, DAY); + + const dir = `${sink.rootDir} /inheritance:r /grant:r ACME\\ada:(OI)(CI)F /T /C /Q`; + expect(calls()).toStrictEqual([ + dir, + `${filePath} /inheritance:r /grant:r ACME\\ada:F /C /Q`, + dir, + ]); + }); + + it("names the user without a domain when USERDOMAIN is unset", async () => { + delete process.env.USERDOMAIN; + const sink = new TelemetrySinkAdapter(); + + await sink.ensureWritable(); + + expect(calls()).toStrictEqual([ + `${sink.rootDir} /inheritance:r /grant:r ada:(OI)(CI)F /T /C /Q`, + ]); + }); + + it("runs icacls for nobody when no account name resolves", async () => { + delete process.env.USERDOMAIN; + process.env.USERNAME = ""; + const sink = new TelemetrySinkAdapter(); + + await sink.ensureWritable(); + await sink.appendRecord(RECORD, DAY); + + expect(calls()).toStrictEqual([]); + }); + + it("leaves a location the person named themselves untouched, directory and day file alike", async () => { + process.env.AIDD_TELEMETRY_DIR = join(sandbox, "shared"); + const sink = new TelemetrySinkAdapter(); + + await sink.ensureWritable(); + await sink.appendRecord(RECORD, DAY); + + expect(calls()).toStrictEqual([]); + }); + } +); diff --git a/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts index d9a3e20b1..dcd0454df 100644 --- a/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts @@ -1,5 +1,8 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { createClaudeCodeTranscriptAccumulator } from "../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; import { createCodexRolloutAccumulator } from "../../../../src/contexts/telemetry/domain/formats/codex-rollout.js"; import { TranscriptCostReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.js"; @@ -37,6 +40,33 @@ describe("TranscriptCostReaderAdapter — Claude Code", () => { expect(await adapter.read("no-such-session")).toEqual({ records: [], sessionFound: false }); }); + const created: string[] = []; + afterEach(() => { + for (const dir of created) rmSync(dir, { recursive: true, force: true }); + created.length = 0; + }); + + it("walks only directories and regular files, so a symlink named like a transcript is no session", async () => { + const home = mkdtempSync(join(tmpdir(), "aidd-transcript-walk-")); + created.push(home); + const projectDir = join(home, ".claude", "projects", "fake-project"); + mkdirSync(projectDir, { recursive: true }); + symlinkSync( + join(HOME_DIR, ".claude", "projects", "fake-project", `${CLAUDE_SID}.jsonl`), + join(projectDir, `${CLAUDE_SID}.jsonl`) + ); + const linkedAdapter = new TranscriptCostReaderAdapter( + home, + CLAUDE_CODE_TRANSCRIPT_LOCATION, + createClaudeCodeTranscriptAccumulator + ); + + await expect(linkedAdapter.read(CLAUDE_SID)).resolves.toEqual({ + records: [], + sessionFound: false, + }); + }); + it("answers with nothing, not an error, when the declared root does not exist", async () => { const adapterWithNoHome = new TranscriptCostReaderAdapter( `${HOME_DIR}/does-not-exist`, diff --git a/cli/tests/contexts/tools/domain/build-hooks-support-declaration.unit.test.ts b/cli/tests/contexts/tools/domain/build-hooks-support-declaration.unit.test.ts index 8649c2308..73557fb4a 100644 --- a/cli/tests/contexts/tools/domain/build-hooks-support-declaration.unit.test.ts +++ b/cli/tests/contexts/tools/domain/build-hooks-support-declaration.unit.test.ts @@ -11,6 +11,8 @@ import { buildCopilotFlatContract } from "../../../../src/contexts/tools/domain/ import { copilot } from "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { buildCursorFlatContract } from "../../../../src/contexts/tools/domain/profiles/cursor/build.js"; import { cursor } from "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { buildKiloFlatContract } from "../../../../src/contexts/tools/domain/profiles/kilo/build.js"; +import { kilo } from "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { buildOpencodeFlatContract } from "../../../../src/contexts/tools/domain/profiles/opencode/build.js"; import { opencode } from "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; @@ -28,6 +30,7 @@ const FLAT_CONTRACTS: ReadonlyArray<[HooksDeclaringTool, () => ToolBuildContract [cursor, buildCursorFlatContract], [copilot, buildCopilotFlatContract], [codex, buildCodexFlatContract], + [kilo, buildKiloFlatContract], [opencode, buildOpencodeFlatContract], ]; diff --git a/cli/tests/contexts/tools/domain/formats/opencode-mcp-merge.unit.test.ts b/cli/tests/contexts/tools/domain/formats/opencode-mcp-merge.unit.test.ts index df7076b69..02638bf44 100644 --- a/cli/tests/contexts/tools/domain/formats/opencode-mcp-merge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/opencode-mcp-merge.unit.test.ts @@ -95,6 +95,27 @@ describe("mergeOpencodeMcp", () => { expect((parsed.mcp["plugin-server"] as { enabled: boolean }).enabled).toBe(false); }); + it("treats a user-edited previously contributed server as a collision on reinstall", () => { + const first = mergeOpencodeMcp( + null, + makeIncoming({ "plugin-server": LOCAL_SERVER }), + new Map(), + hasher + ); + const edited = makeExisting({ "plugin-server": REMOTE_SERVER }); + const next = mergeOpencodeMcp( + edited, + makeIncoming({ "plugin-server": DISABLED_SERVER }), + first.contributedEntries, + hasher + ); + expect(next.collisions).toHaveLength(1); + expect(next.contributedEntries.size).toBe(0); + expect((JSON.parse(next.mergedContent) as { mcp: Record }).mcp).toEqual({ + "plugin-server": REMOTE_SERVER, + }); + }); + it("preserves disabled state (enabled: false) from incoming", () => { const { mergedContent } = mergeOpencodeMcp( null, @@ -220,12 +241,22 @@ describe("buildOpencodeFlatConfig", () => { expect(config.theme).toBe("dark"); expect(config.mcp.user).toEqual(REMOTE_SERVER); }); + + it("preserves duplicate user instructions while appending generated entries once", () => { + const existing = JSON.stringify({ + instructions: ["user.md", "user.md", ".opencode/rules/**/*.md"], + }); + const config = JSON.parse(buildOpencodeFlatConfig(BASE, existing, {}, ["instructions"])) as { + instructions: string[]; + }; + expect(config.instructions).toEqual(["user.md", "user.md", ".opencode/rules/**/*.md"]); + }); }); describe("unmergeOpencodeMcp", () => { it("removes only the tracked entries, preserving other servers", () => { const existing = makeExisting({ plugin: LOCAL_SERVER, user: REMOTE_SERVER }); - const entries = new Map([["plugin", "somehash"]]); + const entries = new Map([["plugin", hasher.hash(JSON.stringify(LOCAL_SERVER)).value]]); const result = unmergeOpencodeMcp(existing, entries); const parsed = JSON.parse(result) as { mcp: Record }; expect(parsed.mcp).not.toHaveProperty("plugin"); @@ -248,4 +279,17 @@ describe("unmergeOpencodeMcp", () => { }; expect(parsed.mcp.server).toEqual(LOCAL_SERVER); }); + + it("refuses to unmerge a tracked server changed by the user", () => { + const entries = new Map([["plugin", hasher.hash(JSON.stringify(LOCAL_SERVER)).value]]); + expect(() => unmergeOpencodeMcp(makeExisting({ plugin: REMOTE_SERVER }), entries)).toThrow( + /edited.*plugin|plugin.*edited/ + ); + }); + + it("refuses an unproven legacy digest before removing a present server", () => { + expect(() => + unmergeOpencodeMcp(makeExisting({ plugin: LOCAL_SERVER }), new Map([["plugin", ""]])) + ).toThrow(/unproven.*plugin|plugin.*unproven/); + }); }); diff --git a/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts b/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts index af6f3ba03..a0c25bf54 100644 --- a/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts +++ b/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts @@ -134,4 +134,11 @@ describe("the sentence each answer carries", () => { expect(entry.answer).toBe("unanswerable"); expect(entry.detail).toBe(`${REGISTRY} could not be read — no reason given`); }); + + it("names ENOENT when the host registry is explicitly absent", () => { + const entry = only(evidence({ reading: { location: REGISTRY, absent: true } })); + + expect(entry.answer).toBe("unanswerable"); + expect(entry.detail).toBe(`${REGISTRY} could not be read — ENOENT`); + }); }); diff --git a/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts b/cli/tests/contexts/tools/domain/mcp-launch-command.unit.test.ts similarity index 84% rename from cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts rename to cli/tests/contexts/tools/domain/mcp-launch-command.unit.test.ts index 960de5f14..841d390b3 100644 --- a/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts +++ b/cli/tests/contexts/tools/domain/mcp-launch-command.unit.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - mcpExclusionEquals, - transformFor, -} from "../../../../src/contexts/tools/domain/mcp-exclusion.js"; +import { transformFor } from "../../../../src/contexts/tools/domain/mcp-launch-command.js"; function makeConfig(servers: Record): string { return JSON.stringify({ mcpServers: servers }, null, 2); @@ -94,13 +91,3 @@ describe("the win32 transform on a config without servers", () => { ); }); }); - -describe("mcpExclusionEquals", () => { - it("is equal only when both the config path and the entry key match", () => { - const one = { configPath: ".mcp.json", entryKey: "a" }; - - expect(mcpExclusionEquals(one, { configPath: ".mcp.json", entryKey: "a" })).toBe(true); - expect(mcpExclusionEquals(one, { configPath: ".mcp.json", entryKey: "b" })).toBe(false); - expect(mcpExclusionEquals(one, { configPath: "other.json", entryKey: "a" })).toBe(false); - }); -}); diff --git a/cli/tests/contexts/tools/domain/plugin-root-token-declaration.unit.test.ts b/cli/tests/contexts/tools/domain/plugin-root-token-declaration.unit.test.ts index 066ef9544..468a9a00d 100644 --- a/cli/tests/contexts/tools/domain/plugin-root-token-declaration.unit.test.ts +++ b/cli/tests/contexts/tools/domain/plugin-root-token-declaration.unit.test.ts @@ -9,6 +9,7 @@ import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import type { PluginsCapability } from "../../../../src/contexts/tools/domain/capabilities/plugins-capability.js"; import { getAiToolConfig } from "../../../../src/contexts/tools/domain/registry.js"; diff --git a/cli/tests/contexts/tools/domain/profiles/claude.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude.unit.test.ts index fc9d6cc55..246749a38 100644 --- a/cli/tests/contexts/tools/domain/profiles/claude.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/claude.unit.test.ts @@ -3,11 +3,26 @@ import { describe, expect, it } from "vitest"; import { claude } from "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; describe("claude", () => { + it("has .claude/ directory", () => { + expect(claude.directory).toBe(".claude/"); + }); + + it("writes agents as markdown", () => { + expect(claude.capabilities.agents.params.format).toBe("markdown"); + }); + describe("capabilities.mcp", () => { it("outputs to .mcp.json", () => { expect(claude.capabilities.mcp.params.outputPath).toBe(".mcp.json"); }); + it("writes its servers as JSON under `mcpServers`", () => { + expect(claude.capabilities.mcp.params).toMatchObject({ + format: "json", + entrySection: "mcpServers", + }); + }); + it("consumes the mcp config name", () => { expect(claude.capabilities.mcp.consumes).toContain("mcp"); }); diff --git a/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts index 3fc7c5d4e..e253cebaa 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts @@ -54,6 +54,11 @@ describe("codex", () => { const path = codex.capabilities.skills.buildInstallPath("my-skill"); expect(path).toBe(".agents/skills/aidd-my-skill/SKILL.md"); }); + + it("keeps a skill folder's name whole, even one ending in .md", () => { + const path = codex.capabilities.skills.buildInstallPath("notes.md/SKILL.md"); + expect(path).toBe(".agents/skills/aidd-notes.md/SKILL.md"); + }); }); it("names the one config file Codex reads, and where it goes", () => { @@ -268,6 +273,21 @@ describe("mergeCodexHooksJson()", () => { expect(mergeCodexHooksJson(once)).toBe(once); }); + it("finds its own hook inside any group of a user's SessionStart, and adds no second one", () => { + const existing = JSON.stringify( + { + SessionStart: [ + { hooks: [{ type: "command", command: "user-start.sh" }] }, + { hooks: [{ type: "command", command: "user-two.sh" }, AIDD_ENTRY.hooks[0]] }, + ], + }, + null, + 2 + ); + + expect(mergeCodexHooksJson(existing)).toBe(existing); + }); + it("starts over from a file it cannot read rather than failing the install", () => { expect(mergeCodexHooksJson("{ not json")).toBe( JSON.stringify({ SessionStart: [AIDD_ENTRY] }, null, 2) @@ -360,6 +380,42 @@ enabled = true const result = mergeCodexConfigToml(existing, MCP_PAYLOAD); expect(result).toContain(".agents/skills"); }); + + it("starts over from a config it cannot parse rather than failing the install", () => { + expect(mergeCodexConfigToml("not = [valid", '[mcp_servers.ctx]\ncommand = "node"\n')).toBe( + 'project_doc_max_bytes = 262144\n\n[mcp_servers.ctx]\ncommand = "node"\n\n[features]\nhooks = true\n' + ); + }); + + it("adds only its defaults when the payload names no mcp server", () => { + expect(mergeCodexConfigToml('[mcp_servers.mine]\ncommand = "x"\n', "")).toBe( + 'project_doc_max_bytes = 262144\n\n[mcp_servers.mine]\ncommand = "x"\n\n[features]\nhooks = true\n' + ); + }); + + it("raises project_doc_max_bytes to what the payload asks when that is above the floor", () => { + expect(mergeCodexConfigToml("", "project_doc_max_bytes = 500000\n")).toBe( + "project_doc_max_bytes = 500000\n\n[features]\nhooks = true\n" + ); + }); + + it("leaves a user's project_doc_max_bytes already at the floor untouched, whatever the payload asks", () => { + expect( + mergeCodexConfigToml("project_doc_max_bytes = 262144\n", "project_doc_max_bytes = 500000\n") + ).toBe("project_doc_max_bytes = 262144\n\n[features]\nhooks = true\n"); + }); + + it("keeps a user's own hooks = false rather than turning hooks on", () => { + expect(mergeCodexConfigToml("[features]\nhooks = false\n", "")).toBe( + "project_doc_max_bytes = 262144\n\n[features]\nhooks = false\n" + ); + }); + + it("turns hooks on beside a user's other features, keeping them", () => { + expect(mergeCodexConfigToml("[features]\nweb_search = true\n", "")).toBe( + "project_doc_max_bytes = 262144\n\n[features]\nweb_search = true\nhooks = true\n" + ); + }); }); /** @@ -392,6 +448,10 @@ describe("a skill's frontmatter, rewritten for Codex", () => { expect(stripCodexSkillFrontmatter({ description: "d" })).toEqual({ description: "d" }); }); + it("writes no description key for a skill that has none", () => { + expect(stripCodexSkillFrontmatter({ name: "n" })).toStrictEqual({ name: "n" }); + }); + it("quotes a value whose colon would otherwise make the frontmatter unreadable", () => { // Not cosmetic: a description containing ": " makes `js-yaml` refuse the source with "bad // indentation of a mapping entry". Re-serialising with quotes is what makes it parse. diff --git a/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts index eb4316145..e1966316b 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts @@ -23,6 +23,14 @@ const HOOKS_JSON = JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: "node journal.cjs" }] }] }, }); +const NO_CONTENT = { + hasAgents: false, + agentsList: [], + skillsList: [], + hasHooksJson: false, + hasMcpJson: false, +}; + function supported(artifact: ArtifactContract): Extract { if (!artifact.supported) throw new Error("artifact is declared unsupported"); return artifact; @@ -92,6 +100,27 @@ describe("buildCodexContract()", () => { expect(manifest).toStrictEqual({ name: "aidd-dev" }); }); + it("copies an author given as a plain name into the manifest", () => { + const manifest = buildCodexContract().synthesizeManifest?.( + { name: "aidd-dev", author: "AIDD" }, + NO_CONTENT + ); + + expect(manifest).toStrictEqual({ name: "aidd-dev", author: "AIDD" }); + }); + + it("copies an author given as an object into the manifest", () => { + const manifest = buildCodexContract().synthesizeManifest?.( + { name: "aidd-dev", author: { name: "AIDD", email: "team@example.test" } }, + NO_CONTENT + ); + + expect(manifest).toStrictEqual({ + name: "aidd-dev", + author: { name: "AIDD", email: "team@example.test" }, + }); + }); + it("sources skills, agents, mcp and hooks from the plugin tree, and neither rules nor commands", () => { const { artifacts } = buildCodexContract(); @@ -128,6 +157,14 @@ describe("buildCodexContract()", () => { }); }); + it("changes only the trailing .md of a staged agent's name to .toml", () => { + const agents = supported(buildCodexContract().artifacts.agents); + + expect(agents.path("aidd-dev", "agents/notes.md-helper.md")).toBe( + "codex-agents/notes.md-helper.toml" + ); + }); + it("keeps only the three frontmatter fields Codex reads in a skill", () => { const transform = supported(buildCodexContract().artifacts.skills).transform; @@ -259,6 +296,14 @@ describe("buildCodexFlatContract()", () => { ); }); + it("changes only the trailing .md of a flat agent's name to .toml", () => { + const agents = supported(buildCodexFlatContract().artifacts.agents); + + expect(agents.path("aidd-dev", "agents/notes.md-helper.md")).toBe( + ".codex/agents/aidd-dev-notes.md-helper.toml" + ); + }); + it("names a flat agent after its plugin, whatever its own frontmatter says", () => { const transform = supported(buildCodexFlatContract().artifacts.agents).transform; @@ -312,4 +357,21 @@ describe("buildCodexFlatContract()", () => { 'project_doc_max_bytes = 262144\n\n[mcp_servers.aidd-dev-context]\ncommand = "node"\n\n[features]\nhooks = true\n', }); }); + + it("writes no mcp_servers table when no built plugin ships an mcp server", async () => { + const fs = new InMemoryFileAdapter(); + + await buildCodexFlatContract().emitConfigArtifact?.( + ["aidd-dev"], + "/out", + "/src", + fs, + { validate: () => undefined }, + { loadConfigAsset: () => ({}), loadSchema: () => ({}) } + ); + + expect(fs.getFile("/out/.codex/config.toml")).toBe( + "project_doc_max_bytes = 262144\n\n[features]\nhooks = true\n" + ); + }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts index 6ec6f14b2..1d1cb9cd5 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts @@ -221,3 +221,30 @@ describe("codexAgentMarkdownToToml()", () => { }); }); }); + +describe("the name a Codex agent falls back to", () => { + it("ignores an empty frontmatter name and names the agent after its plugin and file", () => { + expect( + codexAgentMarkdownToToml( + "---\nname: ''\ndescription: Plans\n---\nBody.\n", + "aidd-dev", + "planner.md" + ) + ).toBe( + 'name = "aidd-dev-planner"\ndescription = "Plans"\ndeveloper_instructions = "Body.\\n"\n' + ); + }); + + it("drops only the trailing .md from the file it names a flat agent after", () => { + expect( + codexAgentMarkdownToToml( + "---\ndescription: Plans\n---\nBody.\n", + "aidd-dev", + "notes.md-helper.md", + true + ) + ).toBe( + 'name = "aidd-dev-notes.md-helper"\ndescription = "Plans"\ndeveloper_instructions = "Body.\\n"\n' + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/codex-transcript-location.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/codex-transcript-location.unit.test.ts new file mode 100644 index 000000000..16cc16469 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/codex-transcript-location.unit.test.ts @@ -0,0 +1,11 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CODEX_ROLLOUT_LOCATION } from "../../../../../../src/contexts/tools/domain/profiles/codex/codex-transcript-location.js"; + +describe("where Codex keeps a session's rollout", () => { + it("claims no file that is not a rollout, even one named for the session", () => { + expect( + CODEX_ROLLOUT_LOCATION.matches(join("2026", "01", "02", "history-abc.jsonl"), "abc") + ).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts index 95cc7a7b0..53a7ce5ca 100644 --- a/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts @@ -1,6 +1,8 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { CONFIG_MCP } from "../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; import { copilot } from "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { nativeActivationOf } from "../../../../../src/contexts/tools/domain/registry.js"; describe("copilot", () => { describe("capabilities.rules.convertFrontmatter()", () => { @@ -29,6 +31,39 @@ describe("copilot", () => { const result = copilot.capabilities.agents.convertFrontmatter(fm); expect(result).toEqual({ name: "alexia", description: "Agent" }); }); + + it("names an unnamed agent after its own file, whatever folders it sits in", () => { + expect( + copilot.capabilities.agents.convertFrontmatter( + { description: "Reviews code" }, + "team/reviews/code-reviewer.md" + ) + ).toStrictEqual({ name: "code-reviewer", description: "Reviews code" }); + }); + + it("drops only the trailing .md from the file it names an agent after", () => { + expect( + copilot.capabilities.agents.convertFrontmatter( + { description: "Helps" }, + "notes.md-helper.md" + ) + ).toStrictEqual({ name: "notes.md-helper", description: "Helps" }); + }); + + it("leaves an agent unnamed when neither its frontmatter nor a file name gives one", () => { + expect( + copilot.capabilities.agents.convertFrontmatter({ description: "Helps" }) + ).toStrictEqual({ + name: undefined, + description: "Helps", + }); + }); + + it("drops a name that is not a string rather than writing it", () => { + expect( + copilot.capabilities.agents.convertFrontmatter({ name: 42, description: "Helps" }, "x.md") + ).toStrictEqual({ name: undefined, description: "Helps" }); + }); }); describe("capabilities.mcp", () => { @@ -41,6 +76,28 @@ describe("copilot", () => { }); }); + describe("capabilities.mcp.params.transformContent()", () => { + const transform = (content: string): string | undefined => + copilot.capabilities.mcp.params.transformContent?.(content); + + it("renames mcpServers to the servers key VS Code reads, keeping every other key", () => { + const content = JSON.stringify({ mcpServers: { ctx: { command: "node" } }, inputs: [] }); + expect(transform(content)).toBe( + JSON.stringify({ inputs: [], servers: { ctx: { command: "node" } } }, null, 2) + ); + }); + + it("returns a config already keyed by servers byte for byte", () => { + const content = JSON.stringify({ servers: { ctx: { command: "node" } } }); + expect(transform(content)).toBe(content); + }); + + it("renames nothing when a config carries both keys, rather than overwriting servers", () => { + const content = JSON.stringify({ servers: { a: {} }, mcpServers: { b: {} } }); + expect(transform(content)).toBe(content); + }); + }); + describe("capabilities.settings", () => { const settings = Array.isArray(copilot.capabilities.settings) ? copilot.capabilities.settings[0] @@ -83,6 +140,26 @@ describe("copilot", () => { const path = copilot.capabilities.commands?.buildInstallPath("commit.md"); expect(path).toBe(".github/prompts/commit.prompt.md"); }); + + it("prefixes a command nested two folders deep with both folders' phase numbers", () => { + const path = copilot.capabilities.commands?.buildInstallPath("01_plan/02_steps/review.md"); + expect(path).toBe(".github/prompts/01-02-review.prompt.md"); + }); + + it("keeps a folder name whole when it does not start with a phase number", () => { + const path = copilot.capabilities.commands?.buildInstallPath("v2-tools/x.md"); + expect(path).toBe(".github/prompts/v2-tools-x.prompt.md"); + }); + + it("does not add the prompt extension twice to a file that already carries it", () => { + const path = copilot.capabilities.commands?.buildInstallPath("review.prompt.md"); + expect(path).toBe(".github/prompts/review.prompt.md"); + }); + + it("adds the prompt extension to a file that carries no extension at all", () => { + const path = copilot.capabilities.commands?.buildInstallPath("commit"); + expect(path).toBe(".github/prompts/commit.prompt.md"); + }); }); describe("capabilities.rules.buildInstallPath()", () => { @@ -96,6 +173,16 @@ describe("copilot", () => { expect(path).toBe(".github/instructions/01-naming.instructions.md"); }); + it("strips a numeric prefix of several digits from a rule's file name", () => { + const path = copilot.capabilities.rules?.buildInstallPath("01-standards/10-mermaid.md"); + expect(path).toBe(".github/instructions/01-mermaid.instructions.md"); + }); + + it("keeps a number inside a rule's file name, stripping only a leading one", () => { + const path = copilot.capabilities.rules?.buildInstallPath("01-standards/naming-2-rules.md"); + expect(path).toBe(".github/instructions/01-naming-2-rules.instructions.md"); + }); + it("flattens rules: strips .copilot tool suffix from filename", () => { const path = copilot.capabilities.rules?.buildInstallPath( "04-tooling/ide-mapping.copilot.md" @@ -117,6 +204,16 @@ describe("copilot", () => { it("returns null for .gitkeep files", () => { expect(copilot.capabilities.agents.buildInstallPath(".gitkeep")).toBeNull(); }); + + it("names a nested agent after its own file, not the folders above it", () => { + const path = copilot.capabilities.agents.buildInstallPath("team/sub/code-reviewer.md"); + expect(path).toBe(".github/agents/code-reviewer.agent.md"); + }); + + it("keeps a file that is not markdown under its own name", () => { + const path = copilot.capabilities.agents.buildInstallPath("helper.txt"); + expect(path).toBe(".github/agents/helper.txt"); + }); }); describe("capabilities.skills.buildInstallPath()", () => { @@ -140,6 +237,24 @@ describe("copilot", () => { ).toEqual({ description: "Apply when editing command files." }); }); + it("joins several path patterns into one comma-separated applyTo", () => { + expect( + copilot.capabilities.rules?.convertFrontmatter({ paths: ["src/**/*.ts", "tests/**/*.ts"] }) + ).toStrictEqual({ applyTo: "src/**/*.ts,tests/**/*.ts" }); + }); + + it("writes no description key when alwaysApply is false and there is no description", () => { + expect(copilot.capabilities.rules?.convertFrontmatter({ alwaysApply: false })).toStrictEqual( + {} + ); + }); + + it("drops the description of a rule that always applies", () => { + expect( + copilot.capabilities.rules?.convertFrontmatter({ description: "Always on." }) + ).toStrictEqual({}); + }); + it("converts globs + alwaysApply: false from framework to applyTo", () => { expect( copilot.capabilities.rules?.convertFrontmatter({ @@ -276,6 +391,54 @@ describe("a reference to another framework file, installed for Copilot", () => { "Everything under [.github/agents/](../../.github/agents/) applies" ); }); + + it("resolves every other section's directory reference to its installed directory", () => { + expect(rewrite("@{{TOOLS}}/commands/ @{{TOOLS}}/rules/ @{{TOOLS}}/skills/")).toBe( + "[.github/prompts/](../../.github/prompts/) " + + "[.github/instructions/](../../.github/instructions/) " + + "[.github/skills/](../../.github/skills/)" + ); + }); + + it("keeps a nested directory reference's folder rather than flattening it", () => { + expect( + rewrite( + "@{{TOOLS}}/agents/team/ @{{TOOLS}}/commands/01-plan/ @{{TOOLS}}/rules/01-standards/" + ) + ).toBe( + "[.github/agents/team/](../../.github/agents/team/) " + + "[.github/prompts/01-plan/](../../.github/prompts/01-plan/) " + + "[.github/instructions/01-standards/](../../.github/instructions/01-standards/)" + ); + }); + + it("keeps a reference to a section's .gitkeep under its own section path", () => { + expect( + rewrite( + "@{{TOOLS}}/agents/.gitkeep @{{TOOLS}}/commands/.gitkeep @{{TOOLS}}/rules/.gitkeep @{{TOOLS}}/skills/.gitkeep" + ) + ).toBe( + "[.github/agents/.gitkeep](../../.github/agents/.gitkeep) " + + "[.github/commands/.gitkeep](../../.github/commands/.gitkeep) " + + "[.github/rules/.gitkeep](../../.github/rules/.gitkeep) " + + "[.github/skills/.gitkeep](../../.github/skills/.gitkeep)" + ); + }); + + it("rewrites every reference in the content, not only the first", () => { + expect( + rewrite("@{{TOOLS}}/agents/a.md @{{TOOLS}}/agents/b.md @{{DOCS}}/a.md @{{DOCS}}/b.md") + ).toBe( + "[.github/agents/a.agent.md](../../.github/agents/a.agent.md) " + + "[.github/agents/b.agent.md](../../.github/agents/b.agent.md) " + + "[aidd_docs/a.md](../../aidd_docs/a.md) " + + "[aidd_docs/b.md](../../aidd_docs/b.md)" + ); + }); + + it("leaves alone a placeholder that only resembles {{TOOLS}}", () => { + expect(rewrite("See @{{TOOLX}}/agents/x.md")).toBe("See @{{TOOLX}}/agents/x.md"); + }); }); describe("a plain path reference, which stays plain text", () => { @@ -329,3 +492,40 @@ describe("a reference to another framework file, installed for Copilot", () => { }); }); }); + +describe("copilot declarations the rest of the CLI reads", () => { + it("probes a plugin manifest in the three places Copilot reads one, in its order", () => { + expect(copilot.distributionProbes?.manifest).toStrictEqual([ + ".plugin/plugin.json", + ".github/plugin/plugin.json", + "plugin.json", + ]); + }); + + it("writes agents as markdown", () => { + expect(copilot.capabilities.agents.params.format).toBe("markdown"); + }); + + it("writes its MCP servers as JSON under `servers`", () => { + expect(copilot.capabilities.mcp.params).toMatchObject({ + format: "json", + entrySection: "servers", + }); + expect(copilot.capabilities.mcp.consumes).toStrictEqual([CONFIG_MCP]); + }); + + it("translates plugins for its own marketplace", () => { + expect(copilot.capabilities.plugins.translationMode).toBe("marketplace"); + }); + + it("drives its own CLI with the verbs it answers to", () => { + expect(nativeActivationOf("copilot")).toMatchObject({ + binary: "copilot", + upgradeVerb: "update", + enableVerb: "install", + disableVerb: "uninstall", + sourceCheckVerb: "update", + forceRemoveArgs: ["--force"], + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts index 88b3e8c4a..356012722 100644 --- a/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts @@ -295,4 +295,12 @@ describe("buildCopilotFlatContract()", () => { ].join("\n") ); }); + + it("drops only the trailing .md from the name it gives a flat agent", () => { + const transform = supported(buildCopilotFlatContract().artifacts.agents).transform; + + expect( + transform?.("---\ndescription: Helps\n---\nBody.\n", "aidd-dev", "notes.md-helper.md") + ).toBe("---\ndescription: 'Helps'\nname: 'aidd-dev-notes.md-helper'\n---\nBody.\n"); + }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/cursor.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor.unit.test.ts index c97ef2758..aa92cb0cb 100644 --- a/cli/tests/contexts/tools/domain/profiles/cursor.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/cursor.unit.test.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { CONFIG_MCP } from "../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; import { cursor } from "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; describe("cursor", () => { @@ -170,3 +171,28 @@ describe("cursor", () => { }); }); }); + +describe("cursor declarations the rest of the CLI reads", () => { + it("has .cursor/ directory", () => { + expect(cursor.directory).toBe(".cursor/"); + }); + + it("probes a marketplace catalog at .cursor-plugin/marketplace.json", () => { + expect(cursor.distributionProbes?.marketplace).toStrictEqual([ + ".cursor-plugin/marketplace.json", + ]); + }); + + it("writes agents as markdown", () => { + expect(cursor.capabilities.agents.params.format).toBe("markdown"); + }); + + it("writes its MCP servers as JSON under `mcpServers`, in .cursor/mcp.json", () => { + expect(cursor.capabilities.mcp.params).toMatchObject({ + outputPath: ".cursor/mcp.json", + format: "json", + entrySection: "mcpServers", + }); + expect(cursor.capabilities.mcp.consumes).toStrictEqual([CONFIG_MCP]); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts new file mode 100644 index 000000000..4534e9652 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import type { ArtifactContract } from "../../../../../../src/contexts/tools/domain/build-contract.js"; +import { buildKiloFlatContract } from "../../../../../../src/contexts/tools/domain/profiles/kilo/build.js"; +import { KiloDualConfigError } from "../../../../../../src/kernel/errors.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; + +function supported(artifact: ArtifactContract): Extract { + if (!artifact.supported) throw new Error("artifact is declared unsupported"); + return artifact; +} + +describe("buildKiloFlatContract", () => { + it("places agents, skills, hook scripts and its generated bridge under .kilo", () => { + const { artifacts } = buildKiloFlatContract(); + expect(supported(artifacts.agents).path("aidd-dev", "agents/reviewer.md")).toBe( + ".kilo/agents/aidd-dev-reviewer.md" + ); + expect(supported(artifacts.skills).path("aidd-dev", "skills/01-plan/SKILL.md")).toBe( + ".kilo/skills/aidd-dev/01-plan/SKILL.md" + ); + const hooks = supported(artifacts.hooks); + expect(hooks.path("aidd-context", "hooks/update_memory.js")).toBe( + ".kilo/hooks/aidd-context/update_memory.js" + ); + expect(hooks.path("aidd-context", "hooks/kilo-plugin.js")).toBe(".kilo/plugin/aidd-context.js"); + expect(hooks.hooksBridge?.path("aidd-context")).toBe(".kilo/plugin/aidd-context-hooks.js"); + }); + + it("writes Kilo's project-local base config and Kilo-compatible MCP entries", async () => { + const fs = new InMemoryFileAdapter({ + "/source/plugins/aidd-dev/.mcp.json": JSON.stringify({ + mcpServers: { context: { command: "node", args: ["server.js"] } }, + }), + }); + await buildKiloFlatContract().emitConfigArtifact?.( + ["aidd-dev"], + "/out", + "/source", + fs, + { validate: () => undefined }, + { + loadConfigAsset: () => ({ + $schema: "https://app.kilo.ai/config.json", + }), + loadSchema: () => ({}), + } + ); + + expect(JSON.parse(fs.getFile("/out/.kilo/kilo.jsonc") ?? "null")).toEqual({ + $schema: "https://app.kilo.ai/config.json", + mcp: { + "aidd-dev-context": { type: "local", command: ["node", "server.js"], enabled: true }, + }, + }); + }); + + it("preserves existing JSONC configuration", async () => { + const fs = new InMemoryFileAdapter({ + "/out/.kilo/kilo.jsonc": + '{\n // project-owned\n "instructions": ["project.md"],\n "theme": "dark",\n}', + }); + const emit = buildKiloFlatContract().emitConfigArtifact; + const asset = { + loadConfigAsset: () => ({ + $schema: "https://app.kilo.ai/config.json", + }), + loadSchema: () => ({}), + }; + await emit?.([], "/out", "/source", fs, { validate: () => undefined }, asset); + const once = fs.getFile("/out/.kilo/kilo.jsonc"); + await emit?.([], "/out", "/source", fs, { validate: () => undefined }, asset); + + expect(JSON.parse(once ?? "null")).toEqual({ + $schema: "https://app.kilo.ai/config.json", + instructions: ["project.md"], + theme: "dark", + }); + expect(fs.getFile("/out/.kilo/kilo.jsonc")).toBe(once); + expect(fs.getFile("/out/.kilo/kilo.json")).toBeUndefined(); + }); + + it("preserves a user-owned MCP server on a generated-key collision", async () => { + const fs = new InMemoryFileAdapter({ + "/out/.kilo/kilo.jsonc": JSON.stringify({ + mcp: { + "aidd-dev-context": { type: "local", command: ["user-server"] }, + }, + }), + "/source/plugins/aidd-dev/.mcp.json": JSON.stringify({ + mcpServers: { context: { command: "node", args: ["server.js"] } }, + }), + }); + await buildKiloFlatContract().emitConfigArtifact?.( + ["aidd-dev"], + "/out", + "/source", + fs, + { validate: () => undefined }, + { + loadConfigAsset: () => ({ $schema: "https://app.kilo.ai/config.json" }), + loadSchema: () => ({}), + } + ); + + const config = JSON.parse(fs.getFile("/out/.kilo/kilo.jsonc") ?? "null") as { + mcp: Record; + }; + expect(config.mcp["aidd-dev-context"].command).toEqual(["user-server"]); + }); + + it("rejects ambiguous Kilo config files without modifying either", async () => { + const fs = new InMemoryFileAdapter({ + "/out/.kilo/kilo.json": "{}", + "/out/.kilo/kilo.jsonc": "{}", + }); + const emit = buildKiloFlatContract().emitConfigArtifact; + + await expect( + emit?.( + [], + "/out", + "/source", + fs, + { validate: () => undefined }, + { + loadConfigAsset: () => ({}), + loadSchema: () => ({}), + } + ) + ).rejects.toThrow(KiloDualConfigError); + expect(fs.getFile("/out/.kilo/kilo.json")).toBe("{}"); + expect(fs.getFile("/out/.kilo/kilo.jsonc")).toBe("{}"); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts new file mode 100644 index 000000000..1df97a930 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts @@ -0,0 +1,123 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it, vi } from "vitest"; +import { + generateKiloHooksBridge, + parseKiloSessionStartHooks, +} from "../../../../../../src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.js"; + +const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; + +describe("Kilo hooks bridge", () => { + it("keeps only replayable SessionStart commands", () => { + expect( + parseKiloSessionStartHooks( + JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { command: `node ${ROOT}/hooks/update_memory.js --quiet` }, + { command: "python ignored.py" }, + ], + }, + ], + Stop: [{ hooks: [{ command: `node ${ROOT}/hooks/stop.js` }] }], + }, + }) + ) + ).toEqual([{ script: "update_memory.js", args: ["--quiet"] }]); + }); + + it("returns no module when hooks.json has no replayable SessionStart command", () => { + expect( + generateKiloHooksBridge(JSON.stringify({ hooks: { Stop: [] } }), "aidd-context") + ).toBeNull(); + }); + + it("generates an importable Kilo default descriptor and maps SessionStart to session.created", async () => { + const generated = generateKiloHooksBridge( + JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ command: `node ${ROOT}/hooks/update_memory.js` }] }], + }, + }), + "aidd-context" + ); + + expect(generated).toContain( + 'export default { id: "aidd-context-hooks", server: AiddContextKiloHooks }' + ); + expect(generated).toContain('event?.type !== "session.created"'); + expect(generated).toContain('hook_event_name: "SessionStart"'); + expect(generated).toContain("Kilo session-start hook"); + + const directory = await mkdtemp(join(tmpdir(), "aidd-kilo-hooks-bridge-")); + try { + const modulePath = join(directory, "bridge.mjs"); + await writeFile(modulePath, generated ?? "", "utf8"); + const module = (await import(pathToFileURL(modulePath).href)) as { + default: { + id: string; + server: { + sessionStartCallsFor: (event: unknown, cwd: string) => readonly unknown[]; + }; + }; + }; + expect(module.default.id).toBe("aidd-context-hooks"); + expect( + module.default.server.sessionStartCallsFor({ type: "session.created" }, "/project") + ).toEqual([ + { + script: "update_memory.js", + args: [], + payload: { hook_event_name: "SessionStart", session_id: null, cwd: "/project" }, + }, + ]); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("reports a hook that exits unsuccessfully without blocking the session", async () => { + const generated = generateKiloHooksBridge( + JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ command: `node ${ROOT}/hooks/fail.js` }] }], + }, + }), + "aidd-context" + ); + const directory = await mkdtemp(join(tmpdir(), "aidd-kilo-hooks-failure-")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + try { + await mkdir(join(directory, "hooks", "aidd-context"), { recursive: true }); + await mkdir(join(directory, "plugin"), { recursive: true }); + await writeFile( + join(directory, "hooks", "aidd-context", "fail.js"), + "process.exit(7);", + "utf8" + ); + const modulePath = join(directory, "plugin", "aidd-context-hooks.mjs"); + await writeFile(modulePath, generated ?? "", "utf8"); + const module = (await import(pathToFileURL(modulePath).href)) as { + default: { + server: (input: { + directory: string; + }) => Promise<{ event: (input: { event: unknown }) => Promise }>; + }; + }; + const server = await module.default.server({ directory }); + await server.event({ event: { type: "session.created" } }); + await vi.waitFor( + () => expect(warn).toHaveBeenCalledWith(expect.stringContaining("exited with code 7")), + { timeout: 5000, interval: 20 } + ); + } finally { + warn.mockRestore(); + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/profile.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/profile.unit.test.ts new file mode 100644 index 000000000..be859c694 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/kilo/profile.unit.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { kilo } from "../../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import { KiloDualConfigError } from "../../../../../../src/kernel/errors.js"; +import { FileHash } from "../../../../../../src/kernel/file.js"; +import type { FileReader } from "../../../../../../src/kernel/ports/file-reader.js"; + +function makeFs(existingPaths: string[]): FileReader { + return { + fileExists: async (path) => + existingPaths.includes(path.replaceAll("\\", "/").replace(/^\/project\//, "")), + isExecutable: async () => false, + realpath: async (path) => path, + readFile: async () => "", + readFileHash: async () => new FileHash("00000000000000000000000000000000"), + listDirectory: async () => [], + listFilesRecursive: async () => [], + }; +} + +describe("kilo", () => { + it("declares a flat build with Kilo's agents and skills paths", () => { + expect(kilo.buildContracts?.flat).toBeDefined(); + expect(kilo.capabilities.agents.buildInstallPath("reviewer.kilo.md")).toBe( + ".kilo/agents/reviewer.md" + ); + expect(kilo.capabilities.skills.buildInstallPath("01-plan/SKILL.kilo.md")).toBe( + ".kilo/skills/01-plan/SKILL.md" + ); + expect(kilo.capabilities.commands.buildInstallPath("01-plan/greet.md")).toBe( + ".kilo/commands/aidd/01/greet.md" + ); + expect(kilo.capabilities.rules.buildInstallPath("standards.kilo.md")).toBe( + ".kilo/rules/standards.md" + ); + }); + + it("prefers project-local JSONC and reuses an existing Kilo config", async () => { + expect(await kilo.capabilities.mcp.resolveOutput("/project", makeFs([]))).toBe( + ".kilo/kilo.jsonc" + ); + expect(await kilo.capabilities.mcp.resolveOutput("/project", makeFs([".kilo/kilo.json"]))).toBe( + ".kilo/kilo.json" + ); + expect(await kilo.capabilities.mcp.resolveOutput("/project", makeFs(["kilo.jsonc"]))).toBe( + "kilo.jsonc" + ); + await expect( + kilo.capabilities.mcp.resolveOutput("/project", makeFs(["kilo.json", "kilo.jsonc"])) + ).rejects.toThrow(KiloDualConfigError); + await expect( + kilo.capabilities.mcp.resolveOutput( + "/project", + makeFs([".kilo/kilo.json", ".kilo/kilo.jsonc"]) + ) + ).rejects.toThrow(KiloDualConfigError); + }); + + it("delivers hook scripts under .kilo/hooks and generates a bridge under .kilo/plugin", () => { + expect(kilo.capabilities.plugins).toMatchObject({ + mode: "flat", + acceptsHooks: true, + flatHooksDir: ".kilo/hooks/", + }); + expect(kilo.capabilities.plugins.flatHooksBridge?.path("aidd-context")).toBe( + ".kilo/plugin/aidd-context-hooks.js" + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts index 5d1b921ee..c9c40137c 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts @@ -92,6 +92,12 @@ describe("opencode", () => { }); expect(result).toEqual({ description: "Apply when editing command files." }); }); + + it("writes no description key when alwaysApply is false and there is no description", () => { + expect(opencode.capabilities.rules?.convertFrontmatter({ alwaysApply: false })).toStrictEqual( + {} + ); + }); }); describe("capabilities.skills.buildInstallPath()", () => { @@ -122,6 +128,11 @@ describe("opencode", () => { it("uses framework-prime merge strategy", () => { expect(opencode.capabilities.mcp.params.mergeStrategy).toBe("framework-prime"); }); + + it("writes its config as JSON, under the mcp key", () => { + const { format, entrySection } = opencode.capabilities.mcp.params; + expect({ format, entrySection }).toStrictEqual({ format: "json", entrySection: "mcp" }); + }); }); describe("capabilities.mcp.transform() (MCP transform)", () => { @@ -339,3 +350,15 @@ describe("opencode", () => { }); }); }); + +describe("opencode.rewriteContent()", () => { + it("routes a numbered command folder under commands/aidd//, with or without the @ prefix", () => { + expect( + opencode.rewriteContent( + "Run .opencode/commands/04_code/implement.md, then @.opencode/commands/02-plan/plan.md.\n" + ) + ).toBe( + "Run .opencode/commands/aidd/04/implement.md, then @.opencode/commands/aidd/02/plan.md.\n" + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts index 730ee5250..6eabd24ca 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { join, posix } from "node:path"; import { describe, expect, it } from "vitest"; import type { ArtifactContract, @@ -8,6 +10,12 @@ import { transformMcpToOpencode, } from "../../../../../../src/contexts/tools/domain/profiles/opencode/build.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { REPOSITORY_ROOT } from "../../../../../helpers/repository-root.js"; + +const OPENCODE_PLUGIN_MODULE = readFileSync( + join(REPOSITORY_ROOT, "plugins/aidd-telemetry/hooks/opencode-plugin.js"), + "utf8" +); // Built, not written literally: biome reads a string holding "${...}" as a lost template. const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; @@ -72,6 +80,11 @@ describe("transformMcpToOpencode()", () => { expect(() => transformMcpToOpencode("[]")).toThrow("MCP config must be a JSON object"); expect(() => transformMcpToOpencode("{ not json")).toThrow(/Cannot parse MCP config/); }); + + it("refuses a config that is null or a bare value", () => { + expect(() => transformMcpToOpencode("null")).toThrow("MCP config must be a JSON object"); + expect(() => transformMcpToOpencode("42")).toThrow("MCP config must be a JSON object"); + }); }); describe("buildOpencodeFlatContract()", () => { @@ -151,6 +164,16 @@ describe("buildOpencodeFlatContract()", () => { ); }); + it("drops only the trailing .md from the name it gives a flat agent", () => { + const transform = supported(buildOpencodeFlatContract().artifacts.agents).transform; + + expect( + transform?.("---\ndescription: Helps\n---\nBody.\n", "aidd-dev", "notes.md-helper.md") + ).toBe( + "---\ndescription: 'Helps'\nname: 'aidd-dev-notes.md-helper'\nmode: 'subagent'\n---\nBody.\n" + ); + }); + it("delivers a plugin's own OpenCode module where the loader scans, and every other script apart", () => { const hooks = supported(buildOpencodeFlatContract().artifacts.hooks); @@ -169,6 +192,20 @@ describe("buildOpencodeFlatContract()", () => { }); }); + it("delivers journal.cjs where the shipped plugin module resolves it, not beside the loader", () => { + const hooks = supported(buildOpencodeFlatContract().artifacts.hooks); + const named = + /JOURNAL_SCRIPT = fileURLToPath\(\s*new URL\("([^"]+)", import\.meta\.url\)/u.exec( + OPENCODE_PLUGIN_MODULE + )?.[1]; + const loaderEntry = hooks.path("aidd-telemetry", "hooks/opencode-plugin.js"); + + expect(named).toBeDefined(); + expect(posix.normalize(posix.join(posix.dirname(loaderEntry), named ?? ""))).toBe( + hooks.path("aidd-telemetry", "hooks/journal.cjs") + ); + }); + it("generates no bridge for a plugin whose hooks name no event OpenCode delivers", () => { const hooks = supported(buildOpencodeFlatContract().artifacts.hooks); @@ -234,4 +271,49 @@ describe("buildOpencodeFlatContract()", () => { json: fs.has("/out/opencode.json"), }).toStrictEqual({ jsonc: true, json: false }); }); + + it("builds opencode.json on opencode's own bundled base config", async () => { + const fs = new InMemoryFileAdapter(); + + await buildOpencodeFlatContract().emitConfigArtifact?.( + [], + "/out", + "/src", + fs, + { validate: () => undefined }, + { + loadConfigAsset: (tool, name) => { + if (tool !== "opencode" || name !== "opencode.json") { + throw new Error(`no bundled asset ${tool}/${name}`); + } + return { $schema: "https://opencode.ai/config.json" }; + }, + loadSchema: () => ({}), + } + ); + + expect(JSON.parse(fs.getFile("/out/opencode.json") ?? "null")).toStrictEqual({ + $schema: "https://opencode.ai/config.json", + }); + }); + + it("takes a bundled base config that comes as text as written", async () => { + const fs = new InMemoryFileAdapter(); + + await buildOpencodeFlatContract().emitConfigArtifact?.( + [], + "/out", + "/src", + fs, + { validate: () => undefined }, + { + loadConfigAsset: () => '{"$schema":"https://opencode.ai/config.json"}', + loadSchema: () => ({}), + } + ); + + expect(JSON.parse(fs.getFile("/out/opencode.json") ?? "null")).toStrictEqual({ + $schema: "https://opencode.ai/config.json", + }); + }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts index e63967f37..c633609e0 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { generateOpencodeHooksBridge } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.js"; +import { + generateOpencodeHooksBridge, + parseHooksJsonForBridge, +} from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.js"; // Built rather than written as a literal "${CLAUDE_PLUGIN_ROOT}" string: biome reads a plain // string holding "${...}" as a forgotten template literal. @@ -148,6 +151,40 @@ describe("generateOpencodeHooksBridge", () => { expect(generateOpencodeHooksBridge(JSON.stringify({ hooks: {} }), "aidd-test")).toBeNull(); }); + it("generates a bridge for a plugin whose hooks name only Stop", () => { + const hooksJson = JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: "command", command: `node ${ROOT}/hooks/journal.cjs turn-end` }] }, + ], + }, + }); + + expect(generateOpencodeHooksBridge(hooksJson, "aidd-test")).toContain( + 'const SESSION_START = [];\nconst STOP = [{"script":"journal.cjs","args":["turn-end"]}];\nconst POST_TOOL_USE = [];\n' + ); + }); + + it("generates a bridge for a plugin whose hooks name only PostToolUse", () => { + const hooksJson = JSON.stringify({ + hooks: { + PostToolUse: [ + { hooks: [{ type: "command", command: `node ${ROOT}/hooks/journal.cjs tool-used` }] }, + ], + }, + }); + + expect(generateOpencodeHooksBridge(hooksJson, "aidd-test")).toContain( + 'const SESSION_START = [];\nconst STOP = [];\nconst POST_TOOL_USE = [{"script":"journal.cjs","args":["tool-used"]}];\n' + ); + }); + + it("names the bridge's export after the plugin even when its name holds an empty segment", () => { + expect(generateOpencodeHooksBridge(THREE_EVENT_HOOKS_JSON, "aidd--sample")).toContain( + "export const AiddSampleHooks = async (input) => {" + ); + }); + it("drops a hook whose command does not invoke node against its own hooks/ script", () => { const hooksJson = JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: `${ROOT}/hooks/check.sh` }] }] }, @@ -156,3 +193,41 @@ describe("generateOpencodeHooksBridge", () => { expect(generateOpencodeHooksBridge(hooksJson, "aidd-test")).toBeNull(); }); }); + +describe("parseHooksJsonForBridge", () => { + const stopCallsOf = (command: string) => + parseHooksJsonForBridge( + JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command }] }] } }) + ).stop; + + it("splits a hook's arguments on any run of whitespace", () => { + expect(stopCallsOf(`node ${ROOT}/hooks/journal.cjs turn-end --quiet`)).toStrictEqual([ + { script: "journal.cjs", args: ["turn-end", "--quiet"] }, + ]); + }); + + it("reads a command written with surrounding whitespace", () => { + expect(stopCallsOf(` node ${ROOT}/hooks/journal.cjs `)).toStrictEqual([ + { script: "journal.cjs", args: [] }, + ]); + }); + + it("skips a hook that carries no command, keeping the ones beside it", () => { + const hooksJson = JSON.stringify({ + hooks: { + Stop: [ + { + hooks: [ + { type: "prompt" }, + { type: "command", command: `node ${ROOT}/hooks/journal.cjs` }, + ], + }, + ], + }, + }); + + expect(parseHooksJsonForBridge(hooksJson).stop).toStrictEqual([ + { script: "journal.cjs", args: [] }, + ]); + }); +}); diff --git a/cli/tests/contexts/tools/domain/registry-conformance.unit.test.ts b/cli/tests/contexts/tools/domain/registry-conformance.unit.test.ts index 052803d28..86460f37c 100644 --- a/cli/tests/contexts/tools/domain/registry-conformance.unit.test.ts +++ b/cli/tests/contexts/tools/domain/registry-conformance.unit.test.ts @@ -6,6 +6,7 @@ import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import type { ToolBuildContract } from "../../../../src/contexts/tools/domain/build-contract.js"; @@ -139,6 +140,7 @@ describe("telemetryLocalRead — exact declarations, phase 2 of local-cost-read" opencode: { kind: "declared" }, copilot: { kind: "declared" }, cursor: { kind: "unsupported", reason: "token count" }, + kilo: { kind: "unsupported", reason: "OpenTelemetry is experimental" }, }; it.each(Object.entries(EXPECTED))("%s", (toolId, expected) => { @@ -392,6 +394,7 @@ describe("every tool says where its own installed rules live", () => { codex: { directory: ".codex/rules/", extension: ".md" }, copilot: { directory: ".github/instructions/", extension: ".instructions.md" }, cursor: { directory: ".cursor/rules/", extension: ".mdc" }, + kilo: { directory: ".kilo/rules/", extension: ".md" }, opencode: { directory: ".opencode/rules/", extension: ".md" }, }; diff --git a/cli/tests/contexts/tools/domain/telemetry-route-supply.unit.test.ts b/cli/tests/contexts/tools/domain/telemetry-route-supply.unit.test.ts index 2c9191acb..e45b601e7 100644 --- a/cli/tests/contexts/tools/domain/telemetry-route-supply.unit.test.ts +++ b/cli/tests/contexts/tools/domain/telemetry-route-supply.unit.test.ts @@ -5,6 +5,7 @@ import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { mapClaudeCodeTranscriptToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; import { mapCodexRolloutToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/codex-rollout.js"; diff --git a/cli/tests/contexts/tools/domain/tool-config.unit.test.ts b/cli/tests/contexts/tools/domain/tool-config.unit.test.ts index 7c037aaa1..ddb69df31 100644 --- a/cli/tests/contexts/tools/domain/tool-config.unit.test.ts +++ b/cli/tests/contexts/tools/domain/tool-config.unit.test.ts @@ -25,8 +25,16 @@ const makeStubConfig = (toolId: AiToolId, toolSuffix: string): AiTool = }); describe("VALID_TOOL_IDS", () => { - it("contains exactly claude, cursor, copilot, opencode, codex, vscode", () => { - expect(VALID_TOOL_IDS).toEqual(["claude", "cursor", "copilot", "opencode", "codex", "vscode"]); + it("contains exactly claude, cursor, copilot, opencode, kilo, codex, vscode", () => { + expect(VALID_TOOL_IDS).toEqual([ + "claude", + "cursor", + "copilot", + "opencode", + "kilo", + "codex", + "vscode", + ]); }); }); @@ -52,7 +60,14 @@ describe("stripToolSuffix()", () => { describe("toolIdsForCategory()", () => { it("returns AI tool IDs for 'ai'", () => { - expect(toolIdsForCategory("ai")).toEqual(["claude", "cursor", "copilot", "opencode", "codex"]); + expect(toolIdsForCategory("ai")).toEqual([ + "claude", + "cursor", + "copilot", + "opencode", + "kilo", + "codex", + ]); }); it("returns IDE tool IDs for 'ide'", () => { diff --git a/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts index 3a6aeecac..79a344e92 100644 --- a/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts @@ -118,11 +118,11 @@ describe("driving the verbs a tool declares, and only those", () => { it("re-indexes marketplaces with the declared verb, and does nothing without one", () => { mockSpawnSync.mockReturnValue(makeResult({})); - adapter().upgradeMarketplaces(); - adapter({}).upgradeMarketplaces(); + adapter().upgradeMarketplaces("owned-catalog"); + adapter({}).upgradeMarketplaces("owned-catalog"); expect(mockSpawnSync.mock.calls.map((call) => call[1])).toStrictEqual([ - ["plugin", "marketplace", "update"], + ["plugin", "marketplace", "update", "owned-catalog"], ]); }); @@ -160,8 +160,8 @@ describe("naming a failure by the tool, the step and what the tool said", () => it("carries the tool's trimmed stderr on a non-zero exit", () => { mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: " boom \n" })); - expect(() => adapter().upgradeMarketplaces()).toThrow( - new NativePluginCliError("probe-tool marketplace update failed: boom") + expect(() => adapter().upgradeMarketplaces("owned-catalog")).toThrow( + new NativePluginCliError("probe-tool marketplace update owned-catalog failed: boom") ); }); diff --git a/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts index 3a8045a45..ceeaaa307 100644 --- a/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts @@ -8,6 +8,7 @@ import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { nativeActivationOf } from "../../../../src/contexts/tools/domain/registry.js"; import { hostMarketplaceRegistryReaders } from "../../../../src/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.js"; diff --git a/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts index 2d3c22910..652cd7a41 100644 --- a/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -118,11 +118,12 @@ describe("Claude Code's own installed_plugins.json", () => { expect(reading.unreadable).toBeUndefined(); }); - it("says it could not read an absent registry, and carries no refs at all", async () => { + it("distinguishes an absent registry from an unreadable one", async () => { const reading = await readerFor("claude").read(PROJECT); expect(reading.refs).toBeUndefined(); - expect(reading.unreadable).toBe("ENOENT"); + expect(reading.absent).toBe(true); + expect(reading.unreadable).toBeUndefined(); }); /** @@ -254,11 +255,12 @@ describe("Codex's own config.toml", () => { expect(refs?.get("b@m")).toEqual({ enabled: false }); }); - it("says it could not read an absent config, and carries no refs", async () => { + it("distinguishes an absent config from an unreadable one", async () => { const reading = await readerFor("codex").read(PROJECT); expect(reading.refs).toBeUndefined(); - expect(reading.unreadable).toBe("ENOENT"); + expect(reading.absent).toBe(true); + expect(reading.unreadable).toBeUndefined(); }); }); @@ -307,11 +309,37 @@ describe("Copilot's own settings.json", () => { expect(reading.unreadable).toBeUndefined(); }); - it("says it could not read an absent settings file", async () => { - expect((await readerFor("copilot").read(PROJECT)).unreadable).toBe("ENOENT"); + it("distinguishes an absent settings file from an unreadable one", async () => { + const reading = await readerFor("copilot").read(PROJECT); + expect(reading.refs).toBeUndefined(); + expect(reading.absent).toBe(true); + expect(reading.unreadable).toBeUndefined(); }); }); +for (const [tool, path] of [ + ["claude", ".claude/plugins/installed_plugins.json"], + ["codex", ".codex/config.toml"], + ["copilot", ".copilot/settings.json"], +] as const) { + it.runIf(process.platform !== "win32" && process.getuid?.() !== 0)( + `${tool} keeps an EACCES registry unproven`, + async () => { + await write(path, "{}\n"); + const target = join(home, path); + await chmod(target, 0o000); + try { + const reading = await readerFor(tool).read(PROJECT); + expect(reading.refs).toBeUndefined(); + expect(reading.absent).toBeUndefined(); + expect(reading.unreadable).toMatch(/EACCES|EPERM/); + } finally { + await chmod(target, 0o600); + } + } + ); +} + describe("Claude Code's registry, at the edges of its shape", () => { const PATH = ".claude/plugins/installed_plugins.json"; diff --git a/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts new file mode 100644 index 000000000..2da2ae663 --- /dev/null +++ b/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts @@ -0,0 +1,250 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; +import { codexMarketplaceSourceListContract } from "../../../../src/contexts/tools/domain/profiles/codex/native-marketplace-source.js"; +import { copilotMarketplaceSourceListContract } from "../../../../src/contexts/tools/domain/profiles/copilot/native-marketplace-source.js"; +import { + HostRegistryMarketplaceSourceReaderAdapter, + NativeMarketplaceSourceReaderAdapter, +} from "../../../../src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.js"; + +vi.mock("node:child_process", () => ({ spawnSync: vi.fn() })); +const spawn = vi.mocked(spawnSync); + +function result(stdout: string, status = 0, stderr = "") { + return { + pid: 1, + output: [], + stdout, + stderr, + status, + signal: null, + error: undefined, + } as ReturnType; +} + +describe("native marketplace source reader", () => { + it("maps Claude's readable marketplace registry to exact file sources", async () => { + const reader = new HostRegistryMarketplaceSourceReaderAdapter({ + read: async () => ({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([["aidd-framework", "/project-a/.aidd/cache/built"]]), + }), + }); + + const reading = await reader.read("/project-b"); + + expect(reading.entries?.get("aidd-framework")).toEqual({ + kind: "registry", + source: "/project-a/.aidd/cache/built", + }); + }); + + it("distinguishes Claude's missing registry from an unreadable one", async () => { + for (const [hostReading, expected] of [ + [{ location: "/known", absent: true as const }, "absent"], + [{ location: "/known", unreadable: "EACCES" }, "unreadable"], + ] as const) { + const reading = await new HostRegistryMarketplaceSourceReaderAdapter({ + read: async () => hostReading, + }).read("/project-b"); + + if (expected === "absent") expect(reading.entries?.size).toBe(0); + else expect(reading.unreadable).toContain("EACCES"); + } + }); + + it("reads Codex's effective configured source with the requesting project's CWD", async () => { + spawn.mockReturnValue( + result( + JSON.stringify({ + marketplaces: [ + { + name: "aidd-framework", + root: "/home/.codex/plugins/marketplaces/aidd-framework", + marketplaceSource: { sourceType: "local", source: "/project-a/.aidd/cache/built" }, + }, + ], + }) + ) + ); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-b"); + + expect(spawn).toHaveBeenCalledWith( + "codex", + ["plugin", "marketplace", "list", "--json"], + expect.objectContaining({ cwd: "/project-b", shell: false }) + ); + expect(reading.entries?.get("aidd-framework")).toEqual({ + kind: "effective-list", + root: "/home/.codex/plugins/marketplaces/aidd-framework", + sourceType: "local", + source: "/project-a/.aidd/cache/built", + }); + }); + + it("proves an empty Codex host only from a valid structured empty list", async () => { + spawn.mockReturnValue(result('{"marketplaces":[]}')); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries?.size).toBe(0); + expect(reading.unreadable).toBeUndefined(); + }); + + it("marks a named Codex row without configured source unproven", async () => { + spawn.mockReturnValue(result('{"marketplaces":[{"name":"foreign","root":"/foreign/cache"}]}')); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries?.has("foreign")).toBe(true); + expect(reading.entries?.get("foreign")).toBeNull(); + }); + + it("rejects unknown JSON shapes and duplicate host names without inferring absence", async () => { + for (const output of [ + '{"plugins":[]}', + '{"marketplaces":[{"name":"aidd-framework","root":7}]}', + '{"marketplaces":[{"name":"aidd-framework","root":"/one"},{"name":"aidd-framework","root":"/two"}]}', + "not JSON", + ]) { + spawn.mockReturnValue(result(output)); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toContain("codex marketplace list"); + } + }); + + it.each([ + ["null listing", null], + ["array listing", []], + ["null catalogue", { marketplaces: null }], + ["object catalogue", { marketplaces: {} }], + ["null row", { marketplaces: [null] }], + ["array row", { marketplaces: [[]] }], + ["missing name", { marketplaces: [{ root: "/cache" }] }], + ["numeric name", { marketplaces: [{ name: 7, root: "/cache" }] }], + ["empty name", { marketplaces: [{ name: "", root: "/cache" }] }], + ["missing root", { marketplaces: [{ name: "foreign" }] }], + ["empty root", { marketplaces: [{ name: "foreign", root: "" }] }], + ])("keeps Codex provenance unreadable for a %s", async (_name, payload) => { + spawn.mockReturnValue(result(JSON.stringify(payload))); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toContain("codex marketplace list could not prove a source"); + }); + + it.each([ + ["null source", null], + ["array source", []], + ["missing source type", { source: "/owned" }], + ["numeric source type", { sourceType: 7, source: "/owned" }], + ["empty source type", { sourceType: "", source: "/owned" }], + ["missing source path", { sourceType: "local" }], + ["numeric source path", { sourceType: "local", source: 7 }], + ["empty source path", { sourceType: "local", source: "" }], + ])("does not accept a Codex cache root as proof of a %s", async (_name, source) => { + spawn.mockReturnValue( + result( + JSON.stringify({ + marketplaces: [{ name: "foreign", root: "/owned/cache", marketplaceSource: source }], + }) + ) + ); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toContain("codex marketplace list could not prove a source"); + }); + + it("rejects the whole Codex listing when a later row has no proven source shape", async () => { + spawn.mockReturnValue( + result( + JSON.stringify({ + marketplaces: [ + { + name: "owned", + root: "/owned/cache", + marketplaceSource: { sourceType: "local", source: "/owned/source" }, + }, + { name: "foreign", root: "/foreign/cache", marketplaceSource: null }, + ], + }) + ) + ); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toContain("codex marketplace list could not prove a source"); + }); + + it.each([ + ['{"plugins":[]}', "unexpected Codex JSON shape"], + ['{"marketplaces":[{"name":"","root":"/cache"}]}', "ambiguous Codex marketplace JSON row"], + [ + '{"marketplaces":[{"name":"foreign","root":"/cache","marketplaceSource":null}]}', + "unproven Codex marketplace source shape", + ], + ])("identifies the provenance repair needed for %s", async (output, diagnosis) => { + spawn.mockReturnValue(result(output)); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toBe( + `codex marketplace list could not prove a source: ${diagnosis}` + ); + }); + + it("gives an actionable Codex diagnostic without treating a failed listing as absence", async () => { + spawn.mockReturnValue(result("", 1, "permission denied")); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toBe( + "Codex marketplace list --json unavailable; inspect the host CLI and retry: permission denied" + ); + }); + + it("refuses Copilot 1.0.83's unsupported --json without a human-text fallback", async () => { + spawn.mockReturnValue(result("", 1, "error: unknown option '--json'")); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + copilotMarketplaceSourceListContract + ).read("/project-a"); + + expect(spawn).toHaveBeenCalledWith( + "copilot", + ["plugin", "marketplace", "list", "--json"], + expect.objectContaining({ cwd: "/project-a", shell: false }) + ); + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toContain("marketplace list --json"); + expect(reading.unreadable).toContain("upgrade"); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.codex.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.codex.integration.test.ts index 79f113cc2..0a5052546 100644 --- a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.codex.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.codex.integration.test.ts @@ -91,21 +91,29 @@ describe("CodexCliAdapter", () => { ); }); - it("upgrades marketplaces via `codex plugin marketplace upgrade`", () => { + it("upgrades only the named marketplace via `codex plugin marketplace upgrade `", () => { mockSpawnSync.mockReturnValue(makeResult({})); new NativePluginCliAdapter("codex", { upgradeVerb: "upgrade", enableVerb: "add", - }).upgradeMarketplaces(); + }).upgradeMarketplaces("owned-catalog"); expect(mockSpawnSync).toHaveBeenCalledWith( "codex", - ["plugin", "marketplace", "upgrade"], + ["plugin", "marketplace", "upgrade", "owned-catalog"], expect.anything() ); }); + it("refuses a targeted plugin update when Codex declares no verified update verb", () => { + mockSpawnSync.mockClear(); + expect(() => + new NativePluginCliAdapter("codex", {}).updatePlugin("aidd-context@real-catalog") + ).toThrow(/does not support targeted native plugin update/); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); + it("enables a plugin via `codex plugin add `", () => { mockSpawnSync.mockReturnValue(makeResult({})); diff --git a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.copilot.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.copilot.integration.test.ts index 14ef06673..98e7f8fa5 100644 --- a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.copilot.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.copilot.integration.test.ts @@ -83,17 +83,17 @@ describe("CopilotCliAdapter", () => { ); }); - it("refreshes marketplaces via `copilot plugin marketplace update`", () => { + it("refreshes only the named marketplace via `copilot plugin marketplace update `", () => { mockSpawnSync.mockReturnValue(makeResult({})); new NativePluginCliAdapter("copilot", { upgradeVerb: "update", enableVerb: "install", - }).upgradeMarketplaces(); + }).upgradeMarketplaces("owned-catalog"); expect(mockSpawnSync).toHaveBeenCalledWith( "copilot", - ["plugin", "marketplace", "update"], + ["plugin", "marketplace", "update", "owned-catalog"], expect.anything() ); }); @@ -113,6 +113,29 @@ describe("CopilotCliAdapter", () => { ); }); + it("updates only the exact Copilot plugin ref, not its entire catalogue", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + new NativePluginCliAdapter("copilot", { updateVerb: "update" }).updatePlugin( + "aidd-context@real-catalog" + ); + expect(mockSpawnSync).toHaveBeenCalledWith( + "copilot", + ["plugin", "update", "aidd-context@real-catalog"], + expect.anything() + ); + }); + + it("reports a failed targeted Copilot update without retrying a catalogue refresh", () => { + mockSpawnSync.mockClear(); + mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: "plugin update failed" })); + expect(() => + new NativePluginCliAdapter("copilot", { updateVerb: "update" }).updatePlugin( + "aidd-context@real-catalog" + ) + ).toThrow(NativePluginCliError); + expect(mockSpawnSync).toHaveBeenCalledTimes(1); + }); + it("throws NativePluginCliError with stderr detail on non-zero exit", () => { mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: 'Marketplace "m1" not found' })); diff --git a/cli/tests/e2e/clean-scope-user.e2e.test.ts b/cli/tests/e2e/clean-scope-user.e2e.test.ts index 8dbbf8c99..fd4119d91 100644 --- a/cli/tests/e2e/clean-scope-user.e2e.test.ts +++ b/cli/tests/e2e/clean-scope-user.e2e.test.ts @@ -202,8 +202,17 @@ describe("E2E: clean --scope user purges the shared source", () => { expect(before).toContain("references.json"); expect(await readJson(join(userConfigDir, "manifest.json")).catch(() => null)).toBeNull(); - const statusBeforeClean = await gitStatusPorcelain(projectDir); + const blocked = await runCli(["clean", "--scope", "user", "--force"], projectDir, fakeHome); + expect(blocked.exitCode).toBe(1); + expect(blocked.stderr).toContain(projectDir); + const projectClean = await runCli( + ["clean", "--scope", "project", "--force"], + projectDir, + fakeHome + ); + expect(projectClean.exitCode).toBe(0); + const statusBeforeClean = await gitStatusPorcelain(projectDir); const cleanResult = await runCli( ["clean", "--scope", "user", "--force"], projectDir, diff --git a/cli/tests/e2e/clean-shared-ref-codex.e2e.test.ts b/cli/tests/e2e/clean-shared-ref-codex.e2e.test.ts index aa53b0ec3..ffbdf9447 100644 --- a/cli/tests/e2e/clean-shared-ref-codex.e2e.test.ts +++ b/cli/tests/e2e/clean-shared-ref-codex.e2e.test.ts @@ -2,18 +2,74 @@ * Codex enables a plugin machine-wide (no `NativeActivation.scopeArgs`), so a `clean` here * must not disable it. `--plugins none` records no `pluginRefs`, so a real name is passed. */ -import { readFile, realpath } from "node:fs/promises"; +import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; import { delimiter, join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestEnv, pathWithoutAidd, runCli, writeFakeToolBinary } from "./helpers.js"; +import { createTestEnv, pathWithoutAidd, runCli } from "./helpers.js"; const FRAMEWORK_REAL_PATH = resolve(process.cwd(), "tests/fixtures/framework-real"); const PLUGIN_NAME = "aidd-vcs"; +/** Every string a parsed JSON tree holds. Asserting against `JSON.stringify` instead compares + * an escaped rendering: on Windows a path's backslashes come back doubled and match nothing. */ +function stringLeavesOf(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap(stringLeavesOf); + if (value !== null && typeof value === "object") + return Object.values(value).flatMap(stringLeavesOf); + return []; +} + async function readJson(path: string): Promise> { return JSON.parse(await readFile(path, "utf-8")) as Record; } +async function writeCodexWithEffectiveSource( + binDir: string, + logFile: string, + stateFile: string, + configFile: string +): Promise { + await mkdir(binDir, { recursive: true }); + const script = join(binDir, "codex-fake.mjs"); + await writeFile( + script, + [ + 'import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";', + 'import { dirname } from "node:path";', + `const logFile = ${JSON.stringify(logFile)};`, + `const stateFile = ${JSON.stringify(stateFile)};`, + `const configFile = ${JSON.stringify(configFile)};`, + "const args = process.argv.slice(2);", + 'writeFileSync(logFile, args.join(" ") + "\\n", { flag: "a" });', + 'if (args[0] === "plugin" && args[1] === "marketplace" && args[2] === "list" && args[3] === "--json") {', + ' const path = existsSync(stateFile) ? readFileSync(stateFile, "utf-8") : undefined;', + ' process.stdout.write(JSON.stringify({ marketplaces: path ? [{ name: "aidd-framework", root: path, marketplaceSource: { sourceType: "local", source: path } }] : [] }));', + '} else if (args[0] === "plugin" && args[1] === "marketplace" && args[2] === "add") {', + " writeFileSync(stateFile, realpathSync(args[3]));", + '} else if (args[0] === "plugin" && args[1] === "add") {', + " mkdirSync(dirname(configFile), { recursive: true });", + ` if (!existsSync(configFile)) writeFileSync(configFile, ${JSON.stringify('[plugins."')} + args[2] + ${JSON.stringify('"]\nenabled = true\n')});`, + "}", + "", + ].join("\n") + ); + if (process.platform === "win32") { + await writeFile( + join(binDir, "codex.cmd"), + `@echo off\r\n"${process.execPath}" "${script}" %*\r\n` + ); + } else { + await writeFile( + join(binDir, "codex"), + `#!/bin/sh\nexec "${process.execPath}" "${script}" "$@"\n`, + { + mode: 0o755, + } + ); + } +} + describe("E2E: clean leaves a codex ref enabled while another project still shares it", () => { it("keeps the plugin's ref enabled, names the other project, and never calls plugin remove", async () => { const first = await createTestEnv("clean-shared-ref-codex-first"); @@ -21,7 +77,12 @@ describe("E2E: clean leaves a codex ref enabled while another project still shar try { const logFile = join(first.tempDir, "codex-invocations.log"); const binDir = join(first.tempDir, "bin"); - await writeFakeToolBinary(binDir, "codex", logFile); + await writeCodexWithEffectiveSource( + binDir, + logFile, + join(first.tempDir, "codex-marketplace-source.txt"), + join(first.fakeHome, ".codex", "config.toml") + ); const env = { PATH: `${binDir}${delimiter}${pathWithoutAidd()}` }; const setupArgs = [ @@ -41,10 +102,21 @@ describe("E2E: clean leaves a codex ref enabled while another project still shar // `second.fakeHome`. const firstSetup = await runCli(setupArgs, first.projectDir, first.fakeHome, { env }); expect(firstSetup.exitCode).toBe(0); + expect(firstSetup.stderr).toBe(""); const secondSetup = await runCli(setupArgs, second.projectDir, first.fakeHome, { env }); expect(secondSetup.exitCode).toBe(0); const secondRoot = await realpath(second.projectDir); + const activationLog = await readFile(logFile, "utf-8"); + expect(activationLog).toContain("plugin marketplace add"); + expect(activationLog).toContain("plugin add"); + expect(await readFile(join(first.fakeHome, ".codex", "config.toml"), "utf-8")).toContain( + `aidd-vcs@aidd-framework` + ); + const machineManifest = await readJson( + join(first.fakeHome, ".config", "aidd", "manifest.json") + ); + expect(stringLeavesOf(machineManifest)).toContain(secondRoot); const referencesBefore = await readJson( join(first.fakeHome, ".config", "aidd", "references.json") ); diff --git a/cli/tests/e2e/framework-build.e2e.test.ts b/cli/tests/e2e/framework-build.e2e.test.ts index 659058548..46129a745 100644 --- a/cli/tests/e2e/framework-build.e2e.test.ts +++ b/cli/tests/e2e/framework-build.e2e.test.ts @@ -593,6 +593,31 @@ describe.concurrent("E2E: aidd translate", () => { } }); + it("--target kilo --flat emits its agents, skills, generated hooks bridge and project config", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-flat-kilo"); + try { + const projRoot = join(tempDir, "proj"); + await mkdir(projRoot, { recursive: true }); + const result = await runCli( + ["translate", FRAMEWORK_PATH, "--to", "kilo", "--as", "flat", "--out", projRoot], + projectDir, + fakeHome + ); + expect(result.exitCode).toBe(0); + expect(existsSync(join(projRoot, ".kilo", "agents"))).toBe(true); + expect(existsSync(join(projRoot, ".kilo", "skills"))).toBe(true); + expect(existsSync(join(projRoot, ".kilo", "plugin", "aidd-test-hooks.js"))).toBe(true); + const kilo = JSON.parse( + await readFile(join(projRoot, ".kilo", "kilo.jsonc"), "utf-8") + ) as Record; + expect(kilo.$schema).toBe("https://app.kilo.ai/config.json"); + expect(kilo.instructions).toBeUndefined(); + expect(kilo.mcp).toBeDefined(); + } finally { + await cleanup(); + } + }); + it("AC #7: --target opencode (non-flat) exits 1 with unsupported error", async () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-opencode-no-flat"); try { diff --git a/cli/tests/e2e/helpers.ts b/cli/tests/e2e/helpers.ts index 751f7dc11..e7471faba 100644 --- a/cli/tests/e2e/helpers.ts +++ b/cli/tests/e2e/helpers.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { accessSync, constants, existsSync } from "node:fs"; -import { copyFile, cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { copyFile, cp, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -57,7 +57,11 @@ export async function createTestEnv(prefix: string): Promise<{ fakeHome: string; cleanup: () => Promise; }> { - const tempDir = await mkdtemp(join(tmpdir(), `aidd-e2e-${prefix}-`)); + // `realpath` because the CLI resolves the roots it prints and compares, and `tmpdir()` does + // not: Windows hands back the 8.3 short form (`C:\\Users\\RUNNER~1\\…`) and macOS a symlink + // (`/var` → `/private/var`). Without this every assertion naming a workspace path compares + // two spellings of the same directory. + const tempDir = await realpath(await mkdtemp(join(tmpdir(), `aidd-e2e-${prefix}-`))); const projectDir = join(tempDir, "project"); const fakeHome = join(tempDir, "home"); await mkdir(projectDir, { recursive: true }); @@ -196,7 +200,16 @@ export function sandboxedEnv( // A minimal PATH, not the runner's own: the OpenCode reader shells out to an `opencode` // binary and waits up to 10s for it, so a machine carrying the tool pays a cost one // without it does not. - const base = { ...withoutGitEnv(process.env), PATH: pathWithoutAidd(), Path: pathWithoutAidd() }; + const base: NodeJS.ProcessEnv = { + ...withoutGitEnv(process.env), + PATH: pathWithoutAidd(), + Path: pathWithoutAidd(), + }; + // The test runner may itself be nested in Codex or Claude. Host session variables must not + // leak into a sandboxed child, otherwise a Claude fixture can be classified as Codex before + // the test's explicit `env` is applied. + delete base.CODEX_THREAD_ID; + delete base.CLAUDE_CODE_SESSION_ID; if (options?.realHome) { return { ...base, ...extra, AIDD_USER_CONFIG_DIR: join(fakeHome, ".config", "aidd") }; } diff --git a/cli/tests/e2e/kilo-runtime.e2e.test.ts b/cli/tests/e2e/kilo-runtime.e2e.test.ts new file mode 100644 index 000000000..da25298f1 --- /dev/null +++ b/cli/tests/e2e/kilo-runtime.e2e.test.ts @@ -0,0 +1,215 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { delimiter, join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { describe, expect, it } from "vitest"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; +import { createTestEnv, execFileAsync, runCli, sandboxedEnv } from "./helpers.js"; + +const describeKiloRuntime = process.env.KILO_RUNTIME_SMOKE === "1" ? describe : describe.skip; +const KILO_PREFIX = join( + process.env.APPDATA ?? join(process.env.USERPROFILE ?? "", "AppData", "Roaming"), + "npm", + "node_modules", + "@kilocode", + "cli" +); +const KILO_BIN = + process.platform !== "win32" + ? resolveKiloBin() + : ([ + join(KILO_PREFIX, "node_modules", "@kilocode", "cli-windows-x64", "bin", "kilo.exe"), + join( + KILO_PREFIX, + "node_modules", + "@kilocode", + "cli-windows-x64-baseline", + "bin", + "kilo.exe" + ), + join(KILO_PREFIX, "node_modules", "@kilocode", "cli-windows-arm64", "bin", "kilo.exe"), + ].find((path) => existsSync(path)) ?? join(process.env.APPDATA ?? "", "npm", "kilo.cmd")); +const KILO_SHELL = KILO_BIN.endsWith(".cmd"); + +function resolveKiloBin(): string { + for (const directory of (process.env.PATH ?? "").split(delimiter)) { + if (directory === "") continue; + const candidate = join(directory, "kilo"); + if (existsSync(candidate)) return candidate; + } + return "kilo"; +} + +function deferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +async function markerContent(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return ""; + } +} + +async function waitForMarker(path: string): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + const content = await markerContent(path); + if (content !== "") return content; + await delay(100); + } + return markerContent(path); +} + +async function startKilo(directory: string, env: NodeJS.ProcessEnv) { + const child = spawn(KILO_BIN, ["serve", "--hostname", "127.0.0.1", "--port", "0"], { + cwd: directory, + env, + shell: KILO_SHELL, + stdio: ["ignore", "pipe", "pipe"], + }); + const output = deferred(); + let text = ""; + const collect = (chunk: Buffer) => { + text += chunk.toString(); + const match = text.match(/kilo server listening on http:\/\/127\.0\.0\.1:(\d+)/); + if (match) output.resolve(match[1]); + }; + child.stdout.on("data", collect); + child.stderr.on("data", collect); + const timer = setTimeout(() => output.reject(new Error(`Kilo did not start:\n${text}`)), 15000); + try { + const port = await output.promise; + return { child, port }; + } catch (error) { + await stopKilo(child); + throw error; + } finally { + clearTimeout(timer); + } +} + +async function stopKilo(child: ReturnType): Promise { + const exited = deferred(); + child.once("exit", () => exited.resolve()); + if (child.exitCode === null) { + child.kill("SIGTERM"); + } + if (child.exitCode !== null) exited.resolve(); + if (KILO_SHELL && process.platform === "win32" && child.pid !== undefined) { + try { + await execFileAsync("taskkill", ["/pid", String(child.pid), "/t", "/f"]); + } catch (error) { + const message = String(error); + if ( + child.exitCode === null && + !message.includes("introuvable") && + !message.includes("not found") + ) { + throw error; + } + } + } + await Promise.race([exited.promise, delay(5000)]); +} + +describeKiloRuntime("E2E: real Kilo runtime", () => { + it("loads the generated plugin and fires session.created through Kilo", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("kilo-runtime"); + let child: ReturnType | undefined; + try { + const generatedProject = join(tempDir, "generated"); + await mkdir(generatedProject, { recursive: true }); + const build = await runCli( + ["translate", REPOSITORY_ROOT, "--to", "kilo", "--as", "flat", "--out", generatedProject], + projectDir, + fakeHome + ); + expect(build.exitCode, build.stderr).toBe(0); + + const env = sandboxedEnv(fakeHome); + const skills = await execFileAsync(KILO_BIN, ["debug", "skill"], { + cwd: generatedProject, + env, + shell: KILO_SHELL, + maxBuffer: 8 * 1024 * 1024, + }); + const discoveredSkills = JSON.parse(skills.stdout) as Array<{ location?: string }>; + const generatedSkillFiles = ( + await readdir(join(generatedProject, ".kilo", "skills"), { + recursive: true, + }) + ).filter((file) => file.replaceAll("\\", "/").endsWith("/SKILL.md")).length; + expect( + discoveredSkills.filter((skill) => + skill.location?.replaceAll("\\", "/").includes(".kilo/skills/aidd-") + ).length + ).toBe(generatedSkillFiles); + + const agents = await execFileAsync(KILO_BIN, ["agent", "list"], { + cwd: generatedProject, + env, + shell: KILO_SHELL, + maxBuffer: 8 * 1024 * 1024, + }); + expect(agents.stdout).toContain("aidd-dev-checker"); + expect(agents.stdout).toContain("aidd-dev-executor"); + + const mcpProject = join(tempDir, "generated-mcp"); + await mkdir(mcpProject, { recursive: true }); + const mcpBuild = await runCli( + [ + "translate", + join(REPOSITORY_ROOT, "cli/tests/fixtures/framework"), + "--to", + "kilo", + "--as", + "flat", + "--out", + mcpProject, + ], + projectDir, + fakeHome + ); + expect(mcpBuild.exitCode, mcpBuild.stderr).toBe(0); + const mcp = await execFileAsync(KILO_BIN, ["mcp", "list"], { + cwd: mcpProject, + env, + shell: KILO_SHELL, + maxBuffer: 8 * 1024 * 1024, + }); + expect(mcp.stdout).toContain("aidd-test-aidd-test-server"); + + const marker = join(generatedProject, "session-start.marker"); + await writeFile( + join(generatedProject, ".kilo", "hooks", "aidd-context", "update_memory.js"), + `require("node:fs").appendFileSync(${JSON.stringify(marker)}, "fired\\n");\n` + ); + const bridgePath = join(generatedProject, ".kilo", "plugin", "aidd-context-hooks.js"); + expect(existsSync(bridgePath)).toBe(true); + + const started = await startKilo(generatedProject, env); + child = started.child; + const response = await fetch(`http://127.0.0.1:${started.port}/session`, { + method: "POST", + headers: { "x-kilo-directory": generatedProject }, + }); + expect(response.ok).toBe(true); + expect(await waitForMarker(marker)).toBe("fired\n"); + } finally { + if (child) await stopKilo(child); + await cleanup(); + } + }, 120000); +}); diff --git a/cli/tests/e2e/persona.e2e.test.ts b/cli/tests/e2e/persona.e2e.test.ts index a25d9ccf5..e690918af 100644 --- a/cli/tests/e2e/persona.e2e.test.ts +++ b/cli/tests/e2e/persona.e2e.test.ts @@ -54,7 +54,7 @@ describe.concurrent("E2E: persona journeys", () => { projectDir, fakeHome, ` -spawn node ${cliPath()} +spawn ${process.execPath} ${cliPath()} expect { -re {AI-Driven Development CLI} { puts "BANNER_OK" } timeout { puts "TIMEOUT"; exit 1 } @@ -241,7 +241,7 @@ exit 0 projectDir, fakeHome, ` -spawn bash -c "cd '${projectDir}' && node ${cliPath()}" +spawn bash -c "cd '${projectDir}' && ${process.execPath} ${cliPath()}" expect { -re {AI-Driven Development CLI} { puts "BANNER_OK" } timeout { puts "TIMEOUT"; exit 1 } diff --git a/cli/tests/e2e/preexisting-codex-ref.e2e.test.ts b/cli/tests/e2e/preexisting-codex-ref.e2e.test.ts new file mode 100644 index 000000000..f9a7bef2a --- /dev/null +++ b/cli/tests/e2e/preexisting-codex-ref.e2e.test.ts @@ -0,0 +1,52 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTestEnv, pathWithoutAidd, runCli, writeFakeToolBinary } from "./helpers.js"; + +const FRAMEWORK_REAL_PATH = resolve(process.cwd(), "tests/fixtures/framework-real"); +const PLUGIN_NAME = "aidd-vcs"; +const THEIR_OWN_ENABLE = `[plugins."${PLUGIN_NAME}@aidd-framework"]\nenabled = true\n`; + +describe("E2E: a codex plugin enabled before setup stays with the person who enabled it", () => { + it("setup never enables it again, and clean never removes it", async () => { + const test = await createTestEnv("preexisting-codex-ref"); + try { + const logFile = join(test.tempDir, "codex-invocations.log"); + const binDir = join(test.tempDir, "bin"); + await writeFakeToolBinary(binDir, "codex", logFile); + const env = { PATH: `${binDir}${delimiter}${pathWithoutAidd()}` }; + const codexConfig = join(test.fakeHome, ".codex", "config.toml"); + await mkdir(join(test.fakeHome, ".codex"), { recursive: true }); + await writeFile(codexConfig, THEIR_OWN_ENABLE); + + const setupArgs = [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "codex", + "--plugins", + PLUGIN_NAME, + "--yes", + ]; + const setup = await runCli(setupArgs, test.projectDir, test.fakeHome, { env }); + expect(setup.exitCode).toBe(0); + const clean = await runCli(["clean", "--force"], test.projectDir, test.fakeHome, { env }); + expect(clean.exitCode).toBe(0); + + const log = await readFile(logFile, "utf-8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return ""; + throw error; + }); + expect(log).toContain("plugin marketplace list --json"); + expect(log).not.toContain("plugin marketplace add"); + expect(log).not.toContain("plugin add"); + expect(log).not.toContain("plugin remove"); + expect(await readFile(codexConfig, "utf-8")).toBe(THEIR_OWN_ENABLE); + } finally { + await test.cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/refused-marketplace-clean.e2e.test.ts b/cli/tests/e2e/refused-marketplace-clean.e2e.test.ts new file mode 100644 index 000000000..1a8a2e105 --- /dev/null +++ b/cli/tests/e2e/refused-marketplace-clean.e2e.test.ts @@ -0,0 +1,79 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTestEnv, pathWithoutAidd, runCli } from "./helpers.js"; + +const FRAMEWORK_REAL_PATH = resolve(process.cwd(), "tests/fixtures/framework-real"); +const SHELL_FAKE_BINARY = process.platform !== "win32"; + +async function writeCopilotThatRefusesMarketplaceAdd(binDir: string, logFile: string) { + await mkdir(binDir, { recursive: true }); + await writeFile( + join(binDir, "copilot"), + [ + "#!/bin/sh", + `echo "$@" >> "${logFile}"`, + 'case "$*" in *"marketplace list --json"*) echo "error: unknown option --json" >&2; exit 1;; esac', + 'case "$*" in *"marketplace add"*) echo "marketplace is already registered" >&2; exit 1;; esac', + "exit 0", + "", + ].join("\n"), + { mode: 0o755 } + ); +} + +describe("E2E: clean leaves a marketplace the host refused to register for this project", () => { + it.runIf(SHELL_FAKE_BINARY)("never asks copilot to remove it", async () => { + const test = await createTestEnv("refused-marketplace-clean"); + try { + const logFile = join(test.tempDir, "copilot-invocations.log"); + const binDir = join(test.tempDir, "bin"); + await writeCopilotThatRefusesMarketplaceAdd(binDir, logFile); + const hostSettings = join(test.fakeHome, ".copilot", "settings.json"); + const hostCache = join(test.fakeHome, ".copilot", "plugins", "probe", "foreign.txt"); + const foreignSettings = '{"enabledPlugins":{"foreign@probe":true}}'; + const foreignCache = "foreign cache bytes"; + await mkdir(join(test.fakeHome, ".copilot", "plugins", "probe"), { recursive: true }); + await writeFile(hostSettings, foreignSettings); + await writeFile(hostCache, foreignCache); + const env = { PATH: `${binDir}${delimiter}${pathWithoutAidd()}` }; + const run = (args: string[]) => runCli(args, test.projectDir, test.fakeHome, { env }); + + const setup = await run([ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "copilot", + "--plugins", + "none", + "--yes", + ]); + expect(setup.exitCode).toBe(0); + const add = await run([ + "marketplace", + "add", + "probe", + FRAMEWORK_REAL_PATH, + "--scope", + "project", + "--yes", + ]); + expect(add.exitCode).toBe(0); + expect(await readFile(logFile, "utf-8")).toContain("marketplace list --json"); + expect(await readFile(logFile, "utf-8")).not.toContain("marketplace add"); + + await writeFile(logFile, ""); + const clean = await run(["clean", "--force"]); + expect(clean.exitCode).toBe(0); + + expect(await readFile(logFile, "utf-8")).not.toContain("marketplace remove"); + expect(await readFile(hostSettings, "utf-8")).toBe(foreignSettings); + expect(await readFile(hostCache, "utf-8")).toBe(foreignCache); + } finally { + await test.cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/sync-recreates-machine-scope-registration.e2e.test.ts b/cli/tests/e2e/sync-recreates-machine-scope-registration.e2e.test.ts index cf3bfb9e8..81e302d79 100644 --- a/cli/tests/e2e/sync-recreates-machine-scope-registration.e2e.test.ts +++ b/cli/tests/e2e/sync-recreates-machine-scope-registration.e2e.test.ts @@ -19,7 +19,7 @@ async function readJson(path: string): Promise> { } describe("E2E: sync recreates a machine-scope registration a fresh clone never carried", () => { - it("registers the shared source and this project's own reference, with native activation unavailable", async () => { + it("registers the shared marketplace without inventing a host-source reference when activation is unavailable", async () => { const origin = await createTestEnv("machine-scope-sync-origin"); const clone = await createTestEnv("machine-scope-sync-clone"); try { @@ -62,10 +62,9 @@ describe("E2E: sync recreates a machine-scope registration a fresh clone never c const names = (marketplaces.marketplaces as Array<{ name: string }>).map((m) => m.name); expect(names).toContain("aidd-framework"); - const references = await readJson(join(clone.fakeHome, ".config", "aidd", "references.json")); - const allProjectRoots = Object.values(references).flat() as string[]; - const expectedRoot = await realpath(clone.projectDir); - expect(allProjectRoots).toContain(expectedRoot); + await expect( + readFile(join(clone.fakeHome, ".config", "aidd", "references.json")) + ).rejects.toMatchObject({ code: "ENOENT" }); } finally { await origin.cleanup(); await clone.cleanup(); diff --git a/cli/tests/e2e/telemetry-check.e2e.test.ts b/cli/tests/e2e/telemetry-check.e2e.test.ts index b63cbad1e..3f9afefa1 100644 --- a/cli/tests/e2e/telemetry-check.e2e.test.ts +++ b/cli/tests/e2e/telemetry-check.e2e.test.ts @@ -208,7 +208,7 @@ describe("aidd telemetry check — the journey and its edge cases", () => { ); const result = await runCli(["telemetry", "check"], projectDir, fakeHome, { - env: { CLAUDE_CODE_SESSION_ID: CLAUDE_SESSION }, + env: { CODEX_THREAD_ID: "", CLAUDE_CODE_SESSION_ID: CLAUDE_SESSION }, }); expect(result.exitCode, result.stderr).toBe(0); @@ -633,7 +633,7 @@ describe("aidd telemetry check — not yet stops being a failure", () => { ); const result = await runCli(["telemetry", "check"], projectDir, fakeHome, { - env: { CLAUDE_CODE_SESSION_ID: CLAUDE_SESSION }, + env: { CODEX_THREAD_ID: "", CLAUDE_CODE_SESSION_ID: CLAUDE_SESSION }, }); expect(result.exitCode, result.stderr).toBe(1); diff --git a/cli/tests/e2e/two-projects-one-home-cursor.e2e.test.ts b/cli/tests/e2e/two-projects-one-home-cursor.e2e.test.ts new file mode 100644 index 000000000..ea6d3cd30 --- /dev/null +++ b/cli/tests/e2e/two-projects-one-home-cursor.e2e.test.ts @@ -0,0 +1,218 @@ +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Manifest } from "../../src/contexts/framework/domain/manifest.js"; +import { copyFixtureTree, createTestEnv, runCli } from "./helpers.js"; + +const FRAMEWORK_REAL_PATH = resolve(process.cwd(), "tests/fixtures/framework-real"); +const PLUGIN = "aidd-context"; + +describe("E2E: two projects on one HOME install the same cursor plugin", () => { + it("gives each project its hooks, and lets the second remove and clean without taking the first's", async () => { + const test = await createTestEnv("two-projects-one-home"); + try { + const second = join(test.tempDir, "second"); + await mkdir(second, { recursive: true }); + const userDir = join(test.fakeHome, ".cursor", "plugins", "local", PLUGIN); + const setup = (cwd: string) => + runCli( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "cursor", + "--plugins", + PLUGIN, + "--yes", + ], + cwd, + test.fakeHome + ); + const hooksOf = (cwd: string) => readFile(join(cwd, ".cursor", "hooks.json"), "utf-8"); + const manifestAt = async (path: string) => + Manifest.fromJSON(JSON.parse(await readFile(path, "utf-8"))); + + expect((await setup(test.projectDir)).exitCode).toBe(0); + expect(await hooksOf(test.projectDir)).toContain("update_memory"); + const firstInstall = await readdir(userDir, { recursive: true }); + expect(firstInstall.length).toBeGreaterThan(0); + + const secondSetup = await setup(second); + expect(secondSetup.exitCode).toBe(0); + expect(await hooksOf(second)).toContain("update_memory"); + const machine = await manifestAt(join(test.fakeHome, ".config", "aidd", "manifest.json")); + const owned = machine.getPlugins("cursor").find((plugin) => plugin.name === PLUGIN); + expect(owned?.files.size).toBeGreaterThan(0); + expect(owned?.dependents).toEqual([await realpath(test.projectDir), await realpath(second)]); + for (const cwd of [test.projectDir, second]) { + const project = await manifestAt(join(cwd, ".aidd", "manifest.json")); + expect( + project.getPlugins("cursor").find((plugin) => plugin.name === PLUGIN)?.files.size + ).toBe(0); + } + const prematureGlobalClean = await runCli( + ["clean", "--scope", "user", "--force"], + test.projectDir, + test.fakeHome + ); + expect(prematureGlobalClean.exitCode).not.toBe(0); + expect(prematureGlobalClean.stdout + prematureGlobalClean.stderr).toContain( + await realpath(second) + ); + expect(await readdir(userDir, { recursive: true })).toEqual(firstInstall); + + const remove = await runCli( + ["plugin", "remove", PLUGIN, "--tool", "cursor"], + second, + test.fakeHome + ); + expect(remove.exitCode).toBe(0); + expect((await runCli(["clean", "--force"], second, test.fakeHome)).exitCode).toBe(0); + expect(await readdir(userDir, { recursive: true })).toEqual(firstInstall); + + expect((await runCli(["clean", "--force"], test.projectDir, test.fakeHome)).exitCode).toBe(0); + expect(existsSync(userDir)).toBe(true); + expect( + (await runCli(["clean", "--scope", "user", "--force"], test.projectDir, test.fakeHome)) + .exitCode + ).toBe(0); + expect(existsSync(userDir)).toBe(false); + } finally { + await test.cleanup(); + } + }); + + it("removes one machine-owned plugin only after both projects detach", async () => { + const test = await createTestEnv("two-projects-targeted-user-remove"); + try { + const second = join(test.tempDir, "second"); + await mkdir(second, { recursive: true }); + for (const cwd of [test.projectDir, second]) { + const setup = await runCli( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "cursor", + "--plugins", + "aidd-context,aidd-dev", + "--yes", + ], + cwd, + test.fakeHome + ); + expect(setup.exitCode).toBe(0); + } + const contextDir = join(test.fakeHome, ".cursor", "plugins", "local", "aidd-context"); + const devDir = join(test.fakeHome, ".cursor", "plugins", "local", "aidd-dev"); + const blocked = await runCli( + ["plugin", "remove", "aidd-context", "--tool", "cursor", "--scope", "user"], + test.projectDir, + test.fakeHome + ); + expect(blocked.exitCode).not.toBe(0); + expect(blocked.stdout + blocked.stderr).toContain(await realpath(second)); + expect(existsSync(contextDir)).toBe(true); + for (const cwd of [test.projectDir, second]) { + expect((await runCli(["clean", "--force"], cwd, test.fakeHome)).exitCode).toBe(0); + } + const removed = await runCli( + ["plugin", "remove", "aidd-context", "--tool", "cursor", "--scope", "user"], + test.projectDir, + test.fakeHome + ); + expect(removed.exitCode).toBe(0); + expect(existsSync(contextDir)).toBe(false); + expect(existsSync(devDir)).toBe(true); + expect( + (await runCli(["clean", "--scope", "user", "--force"], test.projectDir, test.fakeHome)) + .exitCode + ).toBe(0); + } finally { + await test.cleanup(); + } + }); + + it("updates only the selected machine plugin and names dependent projects", async () => { + const test = await createTestEnv("two-projects-targeted-user-update"); + try { + const source = join(test.tempDir, "framework-source"); + const second = join(test.tempDir, "second"); + await copyFixtureTree(FRAMEWORK_REAL_PATH, source); + await mkdir(second, { recursive: true }); + for (const cwd of [test.projectDir, second]) { + expect( + ( + await runCli( + [ + "setup", + "--source", + "local", + "--path", + source, + "--ai", + "cursor", + "--plugins", + "aidd-context,aidd-dev", + "--yes", + ], + cwd, + test.fakeHome + ) + ).exitCode + ).toBe(0); + } + const userBase = join(test.fakeHome, ".cursor", "plugins", "local"); + const devBefore = await readdir(join(userBase, "aidd-dev"), { recursive: true }); + const hooksBefore = await Promise.all( + [test.projectDir, second].map((cwd) => + readFile(join(cwd, ".cursor", "hooks.json"), "utf-8") + ) + ); + const pluginJson = join(source, "plugins", "aidd-context", ".claude-plugin", "plugin.json"); + const marketJson = join(source, ".claude-plugin", "marketplace.json"); + const pluginData = JSON.parse(await readFile(pluginJson, "utf-8")); + pluginData.version = "1.1.0"; + await writeFile(pluginJson, JSON.stringify(pluginData)); + const marketData = JSON.parse(await readFile(marketJson, "utf-8")); + marketData.version = "1.1.0"; + const contextEntry = marketData.plugins.find( + (entry: { name: string }) => entry.name === "aidd-context" + ); + contextEntry.version = "1.1.0"; + await writeFile(marketJson, JSON.stringify(marketData)); + const skill = join(source, "plugins", "aidd-context", "skills", "01-bootstrap", "SKILL.md"); + await writeFile(skill, `${await readFile(skill, "utf-8")}\nMachine update marker.\n`); + const update = await runCli( + ["plugin", "update", "aidd-context", "--tool", "cursor", "--scope", "user"], + test.projectDir, + test.fakeHome + ); + expect(update.exitCode).toBe(0); + expect(update.stdout + update.stderr).toContain(await realpath(second)); + expect( + await readFile( + join(userBase, "aidd-context", "skills", "01-bootstrap", "SKILL.md"), + "utf-8" + ) + ).toContain("Machine update marker."); + expect(await readdir(join(userBase, "aidd-dev"), { recursive: true })).toEqual(devBefore); + expect( + await Promise.all( + [test.projectDir, second].map((cwd) => + readFile(join(cwd, ".cursor", "hooks.json"), "utf-8") + ) + ) + ).toEqual(hooksBefore); + } finally { + await test.cleanup(); + } + }); +}); diff --git a/cli/tests/fixtures/cli-owns-read/expected-envelope.json b/cli/tests/fixtures/cli-owns-read/expected-envelope.json index dd6ee3795..00d396af9 100644 --- a/cli/tests/fixtures/cli-owns-read/expected-envelope.json +++ b/cli/tests/fixtures/cli-owns-read/expected-envelope.json @@ -207,6 +207,20 @@ "requests": 0 } }, + { + "tool": "kilo", + "coverage": "not-covered", + "reason": "Kilo OpenTelemetry is experimental and not yet supported by AIDD.", + "capability": { + "local_read": null, + "export": null, + "journal_attributable": false, + "task_attributable": false + }, + "totals": { + "requests": 0 + } + }, { "tool": "codex", "coverage": "covered", diff --git a/cli/tests/fixtures/framework-real/plugins/aidd-async-dev/skills/01-setup/assets/workflow-template.yml b/cli/tests/fixtures/framework-real/plugins/aidd-async-dev/skills/01-setup/assets/workflow-template.yml index 62da02c13..e69b02f89 100644 --- a/cli/tests/fixtures/framework-real/plugins/aidd-async-dev/skills/01-setup/assets/workflow-template.yml +++ b/cli/tests/fixtures/framework-real/plugins/aidd-async-dev/skills/01-setup/assets/workflow-template.yml @@ -26,7 +26,7 @@ jobs: (github.event_name == 'projects_v2_item') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: __DEFAULT_BRANCH__ fetch-depth: 0 diff --git a/cli/tests/fixtures/framework/plugins/aidd-test/hooks/hooks.json b/cli/tests/fixtures/framework/plugins/aidd-test/hooks/hooks.json index 51eaba2e2..cd671f51e 100644 --- a/cli/tests/fixtures/framework/plugins/aidd-test/hooks/hooks.json +++ b/cli/tests/fixtures/framework/plugins/aidd-test/hooks/hooks.json @@ -1,5 +1,15 @@ { "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.js" + } + ] + } + ], "PreToolUse": [ { "hooks": [ diff --git a/cli/tests/fixtures/framework/plugins/aidd-test/hooks/session-start.js b/cli/tests/fixtures/framework/plugins/aidd-test/hooks/session-start.js new file mode 100644 index 000000000..e954de435 --- /dev/null +++ b/cli/tests/fixtures/framework/plugins/aidd-test/hooks/session-start.js @@ -0,0 +1,2 @@ +// Fixture command replayed by the generated OpenCode and Kilo session-start bridges. +process.exit(0); diff --git a/cli/tests/fixtures/manifests/full.json b/cli/tests/fixtures/manifests/full.json index cd555c5b8..cb115c2b0 100644 --- a/cli/tests/fixtures/manifests/full.json +++ b/cli/tests/fixtures/manifests/full.json @@ -24,12 +24,6 @@ } } ], - "excludedMcp": [ - { - "configPath": ".claude/settings.json", - "entryKey": "old-server" - } - ], "plugins": [ { "name": "aidd-dev", diff --git a/cli/tests/fixtures/manifests/mcp-exclusions.json b/cli/tests/fixtures/manifests/mcp-exclusions.json deleted file mode 100644 index b7d002942..000000000 --- a/cli/tests/fixtures/manifests/mcp-exclusions.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": 8, - "tools": { - "claude": { - "toolId": "claude", - "version": "1.0.0", - "files": [], - "mergeFiles": [], - "excludedMcp": [ - { - "configPath": ".claude/settings.json", - "entryKey": "old-server" - }, - { - "configPath": ".claude/settings.json", - "entryKey": "legacy-server" - } - ] - } - } -} diff --git a/cli/tests/fixtures/plugins/codex-format/marketplace-foreign-aidd/.agents/plugins/marketplace.json b/cli/tests/fixtures/plugins/codex-format/marketplace-foreign-aidd/.agents/plugins/marketplace.json new file mode 100644 index 000000000..ed755c0eb --- /dev/null +++ b/cli/tests/fixtures/plugins/codex-format/marketplace-foreign-aidd/.agents/plugins/marketplace.json @@ -0,0 +1 @@ +{"name":"aidd-framework","plugins":[]} diff --git a/cli/tests/fixtures/plugins/codex-format/marketplace-provenance/.agents/plugins/marketplace.json b/cli/tests/fixtures/plugins/codex-format/marketplace-provenance/.agents/plugins/marketplace.json new file mode 100644 index 000000000..25834e04b --- /dev/null +++ b/cli/tests/fixtures/plugins/codex-format/marketplace-provenance/.agents/plugins/marketplace.json @@ -0,0 +1,4 @@ +{ + "name": "aidd-provenance-fixture", + "plugins": [] +} diff --git a/cli/tests/fixtures/stryker-canary/settings.ts b/cli/tests/fixtures/stryker-canary/settings.ts new file mode 100644 index 000000000..a235a5a8c --- /dev/null +++ b/cli/tests/fixtures/stryker-canary/settings.ts @@ -0,0 +1 @@ +export const SETTINGS: { directory: string } = JSON.parse('{"directory":".cursor/"}'); diff --git a/cli/tests/fixtures/stryker-canary/settings.unit.test.ts b/cli/tests/fixtures/stryker-canary/settings.unit.test.ts new file mode 100644 index 000000000..f461420a2 --- /dev/null +++ b/cli/tests/fixtures/stryker-canary/settings.unit.test.ts @@ -0,0 +1,6 @@ +import { expect, it } from "vitest"; +import { SETTINGS } from "./settings.js"; + +it("reads the directory the settings declare", () => { + expect(SETTINGS).toStrictEqual({ directory: ".cursor/" }); +}); diff --git a/cli/tests/fixtures/unloadable-test-file/broken.fixture-test.ts b/cli/tests/fixtures/unloadable-test-file/broken.fixture-test.ts new file mode 100644 index 000000000..c7889881a --- /dev/null +++ b/cli/tests/fixtures/unloadable-test-file/broken.fixture-test.ts @@ -0,0 +1,4 @@ +import { it } from "vitest"; +import "./throws-on-load.js"; + +it("is never collected", () => {}); diff --git a/cli/tests/fixtures/unloadable-test-file/loads.fixture-test.ts b/cli/tests/fixtures/unloadable-test-file/loads.fixture-test.ts new file mode 100644 index 000000000..c64874137 --- /dev/null +++ b/cli/tests/fixtures/unloadable-test-file/loads.fixture-test.ts @@ -0,0 +1,5 @@ +import { expect, it } from "vitest"; + +it("passes", () => { + expect(true).toBe(true); +}); diff --git a/cli/tests/fixtures/unloadable-test-file/throws-on-load.ts b/cli/tests/fixtures/unloadable-test-file/throws-on-load.ts new file mode 100644 index 000000000..7427a6f96 --- /dev/null +++ b/cli/tests/fixtures/unloadable-test-file/throws-on-load.ts @@ -0,0 +1,5 @@ +export function refuseToLoad(): never { + throw new Error("the module under test failed to load"); +} + +refuseToLoad(); diff --git a/cli/tests/fixtures/unloadable-test-file/vitest.config.ts b/cli/tests/fixtures/unloadable-test-file/vitest.config.ts new file mode 100644 index 000000000..b406d3661 --- /dev/null +++ b/cli/tests/fixtures/unloadable-test-file/vitest.config.ts @@ -0,0 +1,27 @@ +import { writeFileSync } from "node:fs"; +import type { RunnerTask, RunnerTestFile } from "vitest"; +import { defineConfig } from "vitest/config"; +import { UnloadableFileAsFailedTest } from "../../helpers/unloadable-file-as-failed-test.js"; + +function testsOf(task: RunnerTask): RunnerTask[] { + return task.type === "suite" ? task.tasks.flatMap(testsOf) : [task]; +} + +const collectedAsStrykerDoes = { + onFinished(files: RunnerTestFile[]): void { + const tests = files.flatMap(testsOf).filter((test) => test.result); + const seen = tests.map((test) => ({ + file: test.file.name, + state: test.result?.state, + error: test.result?.errors?.[0]?.message, + })); + writeFileSync(String(process.env.COLLECTED), JSON.stringify(seen)); + }, +}; + +export default defineConfig({ + test: { + include: ["*.fixture-test.ts"], + reporters: [new UnloadableFileAsFailedTest(), collectedAsStrykerDoes], + }, +}); diff --git a/cli/tests/golden/framework-build-golden.e2e.test.ts b/cli/tests/golden/framework-build-golden.e2e.test.ts index 1c9da1fd9..9d6686ec8 100644 --- a/cli/tests/golden/framework-build-golden.e2e.test.ts +++ b/cli/tests/golden/framework-build-golden.e2e.test.ts @@ -20,7 +20,7 @@ type TargetSnapshot = Record; // rel-path → sha256 type GoldenSnapshot = Record; // key → files const MARKETPLACE_TARGETS = ["copilot", "codex", "claude", "cursor"] as const; -const FLAT_TARGETS = ["claude", "cursor", "copilot", "codex", "opencode"] as const; +const FLAT_TARGETS = ["claude", "cursor", "copilot", "codex", "opencode", "kilo"] as const; const FROZEN_CELLS = new Set([ ...MARKETPLACE_TARGETS, @@ -107,7 +107,7 @@ async function captureAllCells( return captured; } -describe.concurrent("Framework build golden — 9-cell matrix", () => { +describe.concurrent("Framework build golden — 10-cell matrix", () => { // Two full 9-cell builds measured just past the 60s default on a Windows runner, not a // hang. Raised per test so no other e2e file's budget moves. it("snapshot is deterministic (two captures of each target are byte-identical)", async () => { @@ -154,7 +154,7 @@ describe.concurrent("Framework build golden — 9-cell matrix", () => { } }, 120_000); - it("every one of the 9 cells is byte-identical to its stored baseline", async () => { + it("every one of the 10 cells is byte-identical to its stored baseline", async () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fb-golden-baseline"); try { const captured = await captureAllCells(projectDir, fakeHome, tempDir); diff --git a/cli/tests/golden/snapshots/framework-build/golden.json b/cli/tests/golden/snapshots/framework-build/golden.json index ae1e4c4af..bd0495052 100644 --- a/cli/tests/golden/snapshots/framework-build/golden.json +++ b/cli/tests/golden/snapshots/framework-build/golden.json @@ -188,7 +188,7 @@ "plugins/aidd-async-dev/skills/01-setup/references/auth-modes.md": "4122f895e4b210fce0e8f4cac81a79f6711eca56edac02ac43235359a77c2f62", "plugins/aidd-async-dev/skills/01-setup/evals/scenarios.json": "79b49984a0999a3f34348bedbf8e7fe815ebdddf825701fabfedbe39f7a1ede2", "plugins/aidd-async-dev/skills/01-setup/assets/config-template.json": "d47d1d93014157d525f48f3c96626472d940fafffc0deb106af2371db97b4e83", - "plugins/aidd-async-dev/skills/01-setup/assets/workflow-template.yml": "203382209de920a9405b85cc89817ddf0f731a7604e8a465fc4fb3c32589d168", + "plugins/aidd-async-dev/skills/01-setup/assets/workflow-template.yml": "63be55459862cf9c1f9943ac48dde6f1304a0fd3c3231b608e2217f8ee785d3f", "plugins/aidd-async-dev/skills/01-setup/actions/skills/01-detect-context.md": "83f3ba3022dd75fa2597a872a55e8f75173762f932ed39ae73d8181c0b1b4536", "plugins/aidd-async-dev/skills/01-setup/actions/skills/02-ask-config.md": "986335d1e86a633d0083fe4488aae58266a82ec35d0cdb0b74f0bbcea70e6f4e", "plugins/aidd-async-dev/skills/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", @@ -388,7 +388,7 @@ "plugins/aidd-async-dev/skills/01-setup/references/auth-modes.md": "4122f895e4b210fce0e8f4cac81a79f6711eca56edac02ac43235359a77c2f62", "plugins/aidd-async-dev/skills/01-setup/evals/scenarios.json": "79b49984a0999a3f34348bedbf8e7fe815ebdddf825701fabfedbe39f7a1ede2", "plugins/aidd-async-dev/skills/01-setup/assets/config-template.json": "d47d1d93014157d525f48f3c96626472d940fafffc0deb106af2371db97b4e83", - "plugins/aidd-async-dev/skills/01-setup/assets/workflow-template.yml": "203382209de920a9405b85cc89817ddf0f731a7604e8a465fc4fb3c32589d168", + "plugins/aidd-async-dev/skills/01-setup/assets/workflow-template.yml": "63be55459862cf9c1f9943ac48dde6f1304a0fd3c3231b608e2217f8ee785d3f", "plugins/aidd-async-dev/skills/01-setup/actions/skills/01-detect-context.md": "83f3ba3022dd75fa2597a872a55e8f75173762f932ed39ae73d8181c0b1b4536", "plugins/aidd-async-dev/skills/01-setup/actions/skills/02-ask-config.md": "986335d1e86a633d0083fe4488aae58266a82ec35d0cdb0b74f0bbcea70e6f4e", "plugins/aidd-async-dev/skills/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", @@ -588,7 +588,7 @@ "plugins/aidd-async-dev/skills/01-setup/references/auth-modes.md": "4122f895e4b210fce0e8f4cac81a79f6711eca56edac02ac43235359a77c2f62", "plugins/aidd-async-dev/skills/01-setup/evals/scenarios.json": "79b49984a0999a3f34348bedbf8e7fe815ebdddf825701fabfedbe39f7a1ede2", "plugins/aidd-async-dev/skills/01-setup/assets/config-template.json": "d47d1d93014157d525f48f3c96626472d940fafffc0deb106af2371db97b4e83", - "plugins/aidd-async-dev/skills/01-setup/assets/workflow-template.yml": "203382209de920a9405b85cc89817ddf0f731a7604e8a465fc4fb3c32589d168", + "plugins/aidd-async-dev/skills/01-setup/assets/workflow-template.yml": "63be55459862cf9c1f9943ac48dde6f1304a0fd3c3231b608e2217f8ee785d3f", "plugins/aidd-async-dev/skills/01-setup/actions/skills/01-detect-context.md": "83f3ba3022dd75fa2597a872a55e8f75173762f932ed39ae73d8181c0b1b4536", "plugins/aidd-async-dev/skills/01-setup/actions/skills/02-ask-config.md": "986335d1e86a633d0083fe4488aae58266a82ec35d0cdb0b74f0bbcea70e6f4e", "plugins/aidd-async-dev/skills/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", @@ -788,7 +788,7 @@ "plugins/aidd-async-dev/skills/01-setup/references/auth-modes.md": "4122f895e4b210fce0e8f4cac81a79f6711eca56edac02ac43235359a77c2f62", "plugins/aidd-async-dev/skills/01-setup/evals/scenarios.json": "79b49984a0999a3f34348bedbf8e7fe815ebdddf825701fabfedbe39f7a1ede2", "plugins/aidd-async-dev/skills/01-setup/assets/config-template.json": "d47d1d93014157d525f48f3c96626472d940fafffc0deb106af2371db97b4e83", - "plugins/aidd-async-dev/skills/01-setup/assets/workflow-template.yml": "203382209de920a9405b85cc89817ddf0f731a7604e8a465fc4fb3c32589d168", + "plugins/aidd-async-dev/skills/01-setup/assets/workflow-template.yml": "63be55459862cf9c1f9943ac48dde6f1304a0fd3c3231b608e2217f8ee785d3f", "plugins/aidd-async-dev/skills/01-setup/actions/skills/01-detect-context.md": "83f3ba3022dd75fa2597a872a55e8f75173762f932ed39ae73d8181c0b1b4536", "plugins/aidd-async-dev/skills/01-setup/actions/skills/02-ask-config.md": "986335d1e86a633d0083fe4488aae58266a82ec35d0cdb0b74f0bbcea70e6f4e", "plugins/aidd-async-dev/skills/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", @@ -978,7 +978,7 @@ ".claude/skills/aidd-async-dev-01-setup/references/auth-modes.md": "4122f895e4b210fce0e8f4cac81a79f6711eca56edac02ac43235359a77c2f62", ".claude/skills/aidd-async-dev-01-setup/evals/scenarios.json": "79b49984a0999a3f34348bedbf8e7fe815ebdddf825701fabfedbe39f7a1ede2", ".claude/skills/aidd-async-dev-01-setup/assets/config-template.json": "d47d1d93014157d525f48f3c96626472d940fafffc0deb106af2371db97b4e83", - ".claude/skills/aidd-async-dev-01-setup/assets/workflow-template.yml": "203382209de920a9405b85cc89817ddf0f731a7604e8a465fc4fb3c32589d168", + ".claude/skills/aidd-async-dev-01-setup/assets/workflow-template.yml": "63be55459862cf9c1f9943ac48dde6f1304a0fd3c3231b608e2217f8ee785d3f", ".claude/skills/aidd-async-dev-01-setup/actions/skills/01-detect-context.md": "83f3ba3022dd75fa2597a872a55e8f75173762f932ed39ae73d8181c0b1b4536", ".claude/skills/aidd-async-dev-01-setup/actions/skills/02-ask-config.md": "986335d1e86a633d0083fe4488aae58266a82ec35d0cdb0b74f0bbcea70e6f4e", ".claude/skills/aidd-async-dev-01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", @@ -1169,7 +1169,7 @@ ".cursor/skills/aidd-async-dev-01-setup/references/auth-modes.md": "4122f895e4b210fce0e8f4cac81a79f6711eca56edac02ac43235359a77c2f62", ".cursor/skills/aidd-async-dev-01-setup/evals/scenarios.json": "79b49984a0999a3f34348bedbf8e7fe815ebdddf825701fabfedbe39f7a1ede2", ".cursor/skills/aidd-async-dev-01-setup/assets/config-template.json": "d47d1d93014157d525f48f3c96626472d940fafffc0deb106af2371db97b4e83", - ".cursor/skills/aidd-async-dev-01-setup/assets/workflow-template.yml": "203382209de920a9405b85cc89817ddf0f731a7604e8a465fc4fb3c32589d168", + ".cursor/skills/aidd-async-dev-01-setup/assets/workflow-template.yml": "63be55459862cf9c1f9943ac48dde6f1304a0fd3c3231b608e2217f8ee785d3f", ".cursor/skills/aidd-async-dev-01-setup/actions/skills/01-detect-context.md": "83f3ba3022dd75fa2597a872a55e8f75173762f932ed39ae73d8181c0b1b4536", ".cursor/skills/aidd-async-dev-01-setup/actions/skills/02-ask-config.md": "986335d1e86a633d0083fe4488aae58266a82ec35d0cdb0b74f0bbcea70e6f4e", ".cursor/skills/aidd-async-dev-01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", @@ -1359,7 +1359,7 @@ ".github/skills/aidd-async-dev-01-setup/references/auth-modes.md": "4122f895e4b210fce0e8f4cac81a79f6711eca56edac02ac43235359a77c2f62", ".github/skills/aidd-async-dev-01-setup/evals/scenarios.json": "79b49984a0999a3f34348bedbf8e7fe815ebdddf825701fabfedbe39f7a1ede2", ".github/skills/aidd-async-dev-01-setup/assets/config-template.json": "d47d1d93014157d525f48f3c96626472d940fafffc0deb106af2371db97b4e83", - ".github/skills/aidd-async-dev-01-setup/assets/workflow-template.yml": "203382209de920a9405b85cc89817ddf0f731a7604e8a465fc4fb3c32589d168", + ".github/skills/aidd-async-dev-01-setup/assets/workflow-template.yml": "63be55459862cf9c1f9943ac48dde6f1304a0fd3c3231b608e2217f8ee785d3f", ".github/skills/aidd-async-dev-01-setup/actions/skills/01-detect-context.md": "83f3ba3022dd75fa2597a872a55e8f75173762f932ed39ae73d8181c0b1b4536", ".github/skills/aidd-async-dev-01-setup/actions/skills/02-ask-config.md": "986335d1e86a633d0083fe4488aae58266a82ec35d0cdb0b74f0bbcea70e6f4e", ".github/skills/aidd-async-dev-01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", @@ -1557,7 +1557,7 @@ ".agents/skills/aidd-async-dev-01-setup/references/auth-modes.md": "4122f895e4b210fce0e8f4cac81a79f6711eca56edac02ac43235359a77c2f62", ".agents/skills/aidd-async-dev-01-setup/evals/scenarios.json": "79b49984a0999a3f34348bedbf8e7fe815ebdddf825701fabfedbe39f7a1ede2", ".agents/skills/aidd-async-dev-01-setup/assets/config-template.json": "d47d1d93014157d525f48f3c96626472d940fafffc0deb106af2371db97b4e83", - ".agents/skills/aidd-async-dev-01-setup/assets/workflow-template.yml": "203382209de920a9405b85cc89817ddf0f731a7604e8a465fc4fb3c32589d168", + ".agents/skills/aidd-async-dev-01-setup/assets/workflow-template.yml": "63be55459862cf9c1f9943ac48dde6f1304a0fd3c3231b608e2217f8ee785d3f", ".agents/skills/aidd-async-dev-01-setup/actions/skills/01-detect-context.md": "83f3ba3022dd75fa2597a872a55e8f75173762f932ed39ae73d8181c0b1b4536", ".agents/skills/aidd-async-dev-01-setup/actions/skills/02-ask-config.md": "986335d1e86a633d0083fe4488aae58266a82ec35d0cdb0b74f0bbcea70e6f4e", ".agents/skills/aidd-async-dev-01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", @@ -1742,7 +1742,7 @@ ".opencode/skills/aidd-async-dev/01-setup/references/auth-modes.md": "4122f895e4b210fce0e8f4cac81a79f6711eca56edac02ac43235359a77c2f62", ".opencode/skills/aidd-async-dev/01-setup/evals/scenarios.json": "79b49984a0999a3f34348bedbf8e7fe815ebdddf825701fabfedbe39f7a1ede2", ".opencode/skills/aidd-async-dev/01-setup/assets/config-template.json": "d47d1d93014157d525f48f3c96626472d940fafffc0deb106af2371db97b4e83", - ".opencode/skills/aidd-async-dev/01-setup/assets/workflow-template.yml": "203382209de920a9405b85cc89817ddf0f731a7604e8a465fc4fb3c32589d168", + ".opencode/skills/aidd-async-dev/01-setup/assets/workflow-template.yml": "63be55459862cf9c1f9943ac48dde6f1304a0fd3c3231b608e2217f8ee785d3f", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/01-detect-context.md": "83f3ba3022dd75fa2597a872a55e8f75173762f932ed39ae73d8181c0b1b4536", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/02-ask-config.md": "986335d1e86a633d0083fe4488aae58266a82ec35d0cdb0b74f0bbcea70e6f4e", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", @@ -1754,5 +1754,196 @@ ".opencode/agents/aidd-dev-implementer.md": "4b4fe709e0ed56b097b697a49f6adfc200cccaad36a0e978a12e4f062a3a5e38", ".opencode/agents/aidd-dev-planner.md": "c83648c34068b6fa762fdc6b073e31cb3e9dfe5bd5bf2fd7e2cb4ad3e865feee", ".opencode/agents/aidd-dev-reviewer.md": "30e2dbb93309d23fc99cbce36a4fcfd0b6d76e49c3425979827727327ba697a6" + }, + "kilo:flat": { + ".kilo/kilo.jsonc": "3710c8020f13262b04d8e30ca8eee430c9bf07c7a8bc557d6e9dfb4dffaa8478", + ".kilo/skills/aidd-vcs/04-issue-create/SKILL.md": "3d71c4f442441512d458a180781d51db6e33317578844e76f992ef45fe5ef82b", + ".kilo/skills/aidd-vcs/04-issue-create/evals/scenarios.json": "d113c62aae4867e425737948c39c9f687f56c3d37234eca345a2f65f1a234c30", + ".kilo/skills/aidd-vcs/04-issue-create/assets/CONTRIBUTING.md": "1372c7512c02c26e21643ae523a98d7c90ff92c03633532a8c417b4ca8d9e75f", + ".kilo/skills/aidd-vcs/04-issue-create/assets/issue-template.md": "66b4ef6090208512205fce0bb16edbbcf0ffb19009eae76dc317d738c7a10cdc", + ".kilo/skills/aidd-vcs/04-issue-create/actions/01-issue-create.md": "e3f60414fa3ce2d78583e5cb118dad8df956927aa86e2f6e802031a7d742d7ed", + ".kilo/skills/aidd-vcs/03-release-tag/SKILL.md": "9ab62918018e35219437cada51c447a4e3b620cfd19e034714dd278140a61f8a", + ".kilo/skills/aidd-vcs/03-release-tag/evals/scenarios.json": "fdba5d61f815b956512f84baf712cde92be651593282b43ac173b2459ee9eca3", + ".kilo/skills/aidd-vcs/03-release-tag/assets/release-template.md": "bce05178ae5ea7da6c356849ba69eebd249ba344c94a3c571d7d40be9d4e9e27", + ".kilo/skills/aidd-vcs/03-release-tag/actions/01-release-tag.md": "b2f6c6cce5f7c83f83e4b2fd84c7aae3888bdfcd29f1be3d3e2bc362b18eae6b", + ".kilo/skills/aidd-vcs/02-pull-request/SKILL.md": "b2dbed7de2a3a4b2e646f874747a8e45d5fc25fdfc7c82ce9487e29c7e416ecf", + ".kilo/skills/aidd-vcs/02-pull-request/evals/scenarios.json": "63f63e6b13038672fbe325509deb32a9b25a50a996b687c001dfc74815a0dccd", + ".kilo/skills/aidd-vcs/02-pull-request/assets/CONTRIBUTING.md": "1372c7512c02c26e21643ae523a98d7c90ff92c03633532a8c417b4ca8d9e75f", + ".kilo/skills/aidd-vcs/02-pull-request/assets/README.md": "fb774bb7e5a19a39b21619879ee5045d7b3a0952be3f4b61aea524b93e9c9086", + ".kilo/skills/aidd-vcs/02-pull-request/assets/branch.md": "92880244478c3e48c4f105e3ab2f9c09850778f84f1e44108ff8e912ccb0bea3", + ".kilo/skills/aidd-vcs/02-pull-request/assets/pull_request.md": "66939eaae42c729f3b05f8dcc2546a1e4227441d9d156d692fc7ced89d07a11f", + ".kilo/skills/aidd-vcs/02-pull-request/actions/01-pull-request.md": "3c3753569b5f336e6c53ac8312ba6f9538ccc711e16fb7847ca1ebf04250a790", + ".kilo/skills/aidd-vcs/01-commit/SKILL.md": "7b88897ab9d7a5e2f6d7d9b2d424a8a77d54cdbfe547d94b0288cdacda411a50", + ".kilo/skills/aidd-vcs/01-commit/evals/scenarios.json": "bdefba21f63f7ef737ff3431077278cc45b722e85ba4b66e3a4adf4d34e0b2ff", + ".kilo/skills/aidd-vcs/01-commit/assets/commit-template.md": "b3c392c5c3faecc903bf80eb2bd287d2a02e42d4c3896490a5745d66fca60bf2", + ".kilo/skills/aidd-vcs/01-commit/actions/01-commit.md": "06ded7f8c23e950b3b1a76b0bb89c219d1ca161cb8f9a02e04e8083ad7061dc0", + ".kilo/skills/aidd-refine/03-condense/SKILL.md": "23c039ac8ef9bdcfd96d51803bf8bbdd64942ff8bcaf02eae54a94c889cd69d0", + ".kilo/skills/aidd-refine/03-condense/references/intensity-levels.md": "8e6aa26fc675a2d30dbb4df13e68d0ce6d2844c88bafd39e0ea3f2945e3276f1", + ".kilo/skills/aidd-refine/03-condense/evals/scenarios.json": "c03016b5e98c5a9a8b6680035bf8ee0f2a75edc5ce33e33cf1e1430cd91f55ec", + ".kilo/skills/aidd-refine/03-condense/actions/01-condense.md": "bffb26d5a306bc90f40614b29ca21e9a2151dc5ba82880c857e29c39486a16fd", + ".kilo/skills/aidd-refine/02-challenge/SKILL.md": "124b3f2688cb56887dc1de5118ac1315896aefc4d3440a98088a7020e8acce10", + ".kilo/skills/aidd-refine/02-challenge/references/confidence-rubric.md": "714f1bfd0c33f2adf911c93e9bb2f113be9fd29bad07d83e54be293fc44de823", + ".kilo/skills/aidd-refine/02-challenge/evals/scenarios.json": "332403843b5b5d99a9dcb3464f298ca3b7274edbd1b5c337407a4dd977bc19a5", + ".kilo/skills/aidd-refine/02-challenge/actions/01-challenge.md": "6f47920ccec8682708789e07583e32698695f1274bf883757ab13fca6f3e76c0", + ".kilo/skills/aidd-refine/01-brainstorm/SKILL.md": "1d08f27c800c81ed05ddca0b55813523708fcf7a3be994c43d306d4c290aa212", + ".kilo/skills/aidd-refine/01-brainstorm/references/ambiguity-detection.md": "c869338af0d8e0bdde5915000ce44980fef750cc40d070d4055bcba4c1334e0f", + ".kilo/skills/aidd-refine/01-brainstorm/evals/scenarios.json": "c6858f3b6b8efa666e2c7f21f4325f5e57297e3fdfc475b836f3ce5de9d635bf", + ".kilo/skills/aidd-refine/01-brainstorm/assets/question-templates.md": "bd744429e69f26caf37359e2b1d1c38dc4faba1a2c54dfaa99bfde48da0dc3c8", + ".kilo/skills/aidd-refine/01-brainstorm/actions/01-capture-request.md": "bc133963be4eeb68d6653a65f88f2e1820d711d6d7bf74b79a6225fff7b8e6c8", + ".kilo/skills/aidd-refine/01-brainstorm/actions/02-ask-probing-questions.md": "d8a25b7e2c0861bc4bce878e7d10dcda279855fa0db06d1997ad84f3e5682d54", + ".kilo/skills/aidd-refine/01-brainstorm/actions/03-integrate-answers.md": "b6f2c34f4f03cca48e83ed6fff5936e8ddbecae5fef1f597e99fd764e13f32cd", + ".kilo/skills/aidd-refine/01-brainstorm/actions/04-refine-and-validate.md": "4a6368a3e189c3022f133050f996eda3d9195281a3e4bbabdd120f5b8b414c3c", + ".kilo/skills/aidd-refine/01-brainstorm/actions/05-confirm-approval.md": "3c6c8184910a210bb6698c2c010aebec88a28313b31bbed0ef9c1189530cd6fe", + ".kilo/skills/aidd-pm/05-spec/SKILL.md": "491ab7edd5e2d1aea74e462730fac310df5289298b73fb803b88ea348965ccca", + ".kilo/skills/aidd-pm/05-spec/assets/spec-template.md": "2f7e446c4ec58d05a471212e9ab3ba64395bdd059943620d02bf1bf5c98777c2", + ".kilo/skills/aidd-pm/05-spec/assets/spec-validator.yml": "2358c6f0baa5656ddcff9410149cf8261d54199d8722f77769f76ca0d7c8cf18", + ".kilo/skills/aidd-pm/04-clarity/SKILL.md": "384d1711225afd5270cb6276f9d65ed7390812fbac4de49a2e9735d88dcf486a", + ".kilo/skills/aidd-pm/03-prd/SKILL.md": "d68b21e05bfe9a66f051948ce95317b72245fd05f48a8e9acf3d339ba3538469", + ".kilo/skills/aidd-pm/03-prd/assets/prd-template.md": "af01423720e8c9527ff12a2e9ad1de39c6e63c6569359be08defd0a059aabea9", + ".kilo/skills/aidd-pm/03-prd/assets/task-template.md": "c0069ed2ecce4742629dccd6be709d4e5a71f3db3821310c381c054aeb479289", + ".kilo/skills/aidd-pm/03-prd/actions/01-prd.md": "c0da6597f8c465ad50a17a07543152f53de1fbccc28cd906763d439004084a22", + ".kilo/skills/aidd-pm/02-user-stories-create/SKILL.md": "1cb352618908536a4605ca12ff82e2f3ca4ebce44261f44c6350ec2f758185aa", + ".kilo/skills/aidd-pm/02-user-stories-create/assets/user-story-template.md": "59ecd98d00057313414a74a94c19c0f2d167957e1080b880ee68a69a31948320", + ".kilo/skills/aidd-pm/02-user-stories-create/actions/01-create-user-stories.md": "6fad8968a1e787c197764c4a8c09b17c521ae5ea2286a5082addc717bd72b1e6", + ".kilo/skills/aidd-pm/01-ticket-info/SKILL.md": "e2fd826ddfde64bbe9f8287f2493b4117ce6bd0ad3485bbce896e235077dd513", + ".kilo/skills/aidd-pm/01-ticket-info/actions/01-ticket-info.md": "f075ea6ff626534dbe0826d06b67fae93b6488ec4c5c7a3d857f1d05f73d0630", + ".kilo/skills/aidd-dev/08-for-sure/SKILL.md": "dbcce912442017c2e091365bfb2b9ee75fb272327d213e2ebd9bcab7dab24248", + ".kilo/skills/aidd-dev/08-for-sure/actions/01-init-tracking.md": "3ce6bb19135e5d31c4d2ad1c2151c1563886a611f2399b47624368268a79dcdf", + ".kilo/skills/aidd-dev/08-for-sure/actions/02-auto-accept.md": "235818118c772c0f499ea0668bfbc7bd00c2aae34e6bbd8b13b912fddfa79705", + ".kilo/skills/aidd-dev/08-for-sure/actions/03-autonomous-loop.md": "b19d4520ad716def3c2a4611c37725cba9af713cd4bcb668c237e874e1e43843", + ".kilo/skills/aidd-dev/07-debug/SKILL.md": "f93f3eb8a71c580692e2048c9d475e1933c0717cc86881bdea8e889f7fbfd498", + ".kilo/skills/aidd-dev/07-debug/references/mermaid-conventions.md": "85826285744909dd4c4706b82f0dbeff4f88a8f191cb22d538aa12c3c96365eb", + ".kilo/skills/aidd-dev/07-debug/assets/task-template.md": "c0069ed2ecce4742629dccd6be709d4e5a71f3db3821310c381c054aeb479289", + ".kilo/skills/aidd-dev/07-debug/actions/01-reproduce.md": "7e902e5aadc9162b444b341deb33f2a683000c1cc4722ad856496f9d740ec17d", + ".kilo/skills/aidd-dev/07-debug/actions/02-debug.md": "dc3900ce6fb76074b88034d7b7316f4051d78a1828caa3acef9786e8ea582b18", + ".kilo/skills/aidd-dev/07-debug/actions/03-reflect-issue.md": "3d5f18634618737870da4c783c7525cee25e09d942faee80125dd7536978ce12", + ".kilo/skills/aidd-dev/06-refactor/SKILL.md": "914ac56ba86d951b01dd90b3ec23760ccba233f5b6357ed9987c8aa3e0a44f8c", + ".kilo/skills/aidd-dev/06-refactor/actions/01-performance.md": "1f7d500967de58875aeb14b732a1af377b17edff8654f4365b3f29743debb3c7", + ".kilo/skills/aidd-dev/06-refactor/actions/02-security.md": "db55b81ab824d81b765189296deb1ce31fdb134edac186baf22a2c3bb1cde938", + ".kilo/skills/aidd-dev/05-test/SKILL.md": "a65b9feb5b674a0cdef00934cf4cc0d7884e9728b8228add38b720493db82637", + ".kilo/skills/aidd-dev/05-test/actions/01-test.md": "f6db9653cd29729c51653df7afe473fae4ae45bb126a694a367bfd84fa1785a4", + ".kilo/skills/aidd-dev/05-test/actions/02-test-journey.md": "1eeb85da69abc3f1fc3e5671e83962f44fcb5686d9ca5972eeb2ba4cab31306d", + ".kilo/skills/aidd-dev/04-review/SKILL.md": "c9c2552d6f5375b1b8004d948e5ae608607bfb6884325dfa7635b84c9fdf7b95", + ".kilo/skills/aidd-dev/04-review/assets/code-review-template.md": "e270c4b6b8c69e4fbbbcc08c2f91cc9a09fc8ad155b6f52cec3e3d34a262324d", + ".kilo/skills/aidd-dev/04-review/assets/review-functional-template.md": "a84b48347caf4d09d84994b07329e4d05c8053d45630b37d47ca3ccb386e3146", + ".kilo/skills/aidd-dev/04-review/assets/review-template.md": "b0ad0ab703e4d9ed960bd324bc37efe5f682cbcd40ed6293791d9074f174201c", + ".kilo/skills/aidd-dev/04-review/actions/01-review-code.md": "8d5e4fc6c9243a83025961ae40146f1d96f321d9ff4d93ab74c370bbc53c18ed", + ".kilo/skills/aidd-dev/04-review/actions/02-review-functional.md": "9e5d837715f5610a42f1c294b0fa71277538e2629dc47cf09675487dcfa98324", + ".kilo/skills/aidd-dev/03-audit/SKILL.md": "7947c0dc728f51d82dd1e605e315458c1855664a92783d30acd927f263e58fc4", + ".kilo/skills/aidd-dev/03-audit/actions/01-audit.md": "0371ae3d0c9a383f8b90657f3381717f323742bad922df2c1c508f6db013267e", + ".kilo/skills/aidd-dev/02-assert/SKILL.md": "e17e52f9342a882be2a483c02911b4c02db21b8f6732a45a50cb5c84a89b152a", + ".kilo/skills/aidd-dev/02-assert/assets/task-template.md": "c0069ed2ecce4742629dccd6be709d4e5a71f3db3821310c381c054aeb479289", + ".kilo/skills/aidd-dev/02-assert/actions/01-assert.md": "b8a9680e7cf956f2e1ff7720f755586e2523d6951c35ec686ded659f7fa3a4b1", + ".kilo/skills/aidd-dev/02-assert/actions/02-assert-architecture.md": "e5d7effa39f33c045e4d605ed909d46ddf7562e2a0199eb2e39fcc8f3fc92620", + ".kilo/skills/aidd-dev/02-assert/actions/03-assert-frontend.md": "ed9751b32580c7fd3fbe02a8205cbb5d85a8e7bc38f982b5130b810950bbd091", + ".kilo/skills/aidd-dev/01-plan/SKILL.md": "17bfdcd576432b2c0d9aee7b2cdcef5888bd2e7a2e5ee28f72b15d7b4e4b7bbd", + ".kilo/skills/aidd-dev/01-plan/references/mermaid-conventions.md": "85826285744909dd4c4706b82f0dbeff4f88a8f191cb22d538aa12c3c96365eb", + ".kilo/skills/aidd-dev/01-plan/assets/master-plan-template.md": "f793f2bc8fad34f056e824def49527552f50cf12e43f0929731d64278376b822", + ".kilo/skills/aidd-dev/01-plan/assets/plan-template.md": "cf462e831d994230c811c71eaa72a8f9880536ba3b657b70342083dee00ab254", + ".kilo/skills/aidd-dev/01-plan/assets/tech-choice-template.md": "c9f16f9b598aded5953127b4bf2768b95d7abda0b75d937e0b31dfee9317c40a", + ".kilo/skills/aidd-dev/01-plan/actions/01-plan.md": "c480fc1cffe39f117003b5aeea23e10f808952a0eeea067dbc0ab44c0af992ff", + ".kilo/skills/aidd-dev/01-plan/actions/02-components-behavior.md": "cacb673334d8947c3c252feaaf0ef50c269c06c5cf8f51b45663cbd42402e094", + ".kilo/skills/aidd-dev/01-plan/actions/03-image-extract-details.md": "6f6cddd0893888c71b0aed5ecfe48217afe0eef10feec6415db433bfcc69f8e3", + ".kilo/skills/aidd-dev/00-sdlc/SKILL.md": "5cfd0552e267b714302f535418fdcad6d0fadd463503f464e49637e7beab6913", + ".kilo/skills/aidd-context/06-discovery/SKILL.md": "977b633166ecf11224966df8a19d4661252269d5459fe3524987c705f995bae1", + ".kilo/skills/aidd-context/06-discovery/actions/01-find-skill.md": "600caf7822017bfe75d40773e1673ada784227b98f5b88d4ba98a57822fa0dcf", + ".kilo/skills/aidd-context/05-learn/SKILL.md": "c877eeb8892249fc9a13ea73d63493973fcfcc00c81709011e2cfcd788aa812b", + ".kilo/skills/aidd-context/05-learn/assets/adr-template.md": "1f9feb18109b178226885ab7edabc3d1c4dd2a77dd1fa7345be856643107ac16", + ".kilo/skills/aidd-context/05-learn/assets/decision-template.md": "1e6229157fd0a07c090ce0bd159336a13554975e146966b306bf25665a179938", + ".kilo/skills/aidd-context/05-learn/actions/01-learn.md": "92400bfcc2cdfe9f0bad8f2feadd7d28dce882240fe58971d25dfa880521d8bb", + ".kilo/skills/aidd-context/04-mermaid/SKILL.md": "52b138ad705b624e09b77e6467cb2caddd85f3c172cf0ffde83a4981cc82309c", + ".kilo/skills/aidd-context/04-mermaid/references/mermaid-conventions.md": "85826285744909dd4c4706b82f0dbeff4f88a8f191cb22d538aa12c3c96365eb", + ".kilo/skills/aidd-context/04-mermaid/actions/01-mermaid.md": "f70b18f429701d0c3b46a4c6e75153c732ee9e06a16aa01efec05e810ac7a6dd", + ".kilo/skills/aidd-context/03-context-generate/SKILL.md": "56bc1ab9a3e93f65285c265ef07dbb96ac34a1c42294dbf4658147a524d0c373", + ".kilo/skills/aidd-context/03-context-generate/references/agents-coordination.md": "eaaa31ed554a53f7870151307853e1add7e3ddd1de8e442b8d5e7c5698ab0098", + ".kilo/skills/aidd-context/03-context-generate/references/ai-mapping.md": "4ad192b5ae53c7ceadff165a1cef8c3f24949498375bedc0cc359194dea3117e", + ".kilo/skills/aidd-context/03-context-generate/references/naming-conventions.md": "fd39d00b1449f0770ee89aad9f6e84d21e1fee2efc0ff547a240b0e18f648cd8", + ".kilo/skills/aidd-context/03-context-generate/references/rule-structure.md": "c8ce6eea6dbaae5309a77237d702db193a7748db5c581d0918f17db3989e414f", + ".kilo/skills/aidd-context/03-context-generate/references/rule-writing.md": "e4cddd88dca3162d576a1b616c419f5ff2f3b7681a09a569da68358f3713c9e6", + ".kilo/skills/aidd-context/03-context-generate/references/skill-structure.md": "2b6114ff8aa30f1a5d7bfd3bf0d7d87e429cb8d65ba13b369e6cdc9c8bec501c", + ".kilo/skills/aidd-context/03-context-generate/evals/scenarios.json": "db355f13f2ca3499f912f39ada136472f9bc993846564aeaeb6eab371d485520", + ".kilo/skills/aidd-context/03-context-generate/assets/skills/action-template.md": "8bd80e05dcbb10e24adb668778fd9dfb058c92193c52c6012e9f5cc11305103c", + ".kilo/skills/aidd-context/03-context-generate/assets/skills/evals-template.md": "c0468fe9869895b42f7a59b48f7613ca4ef3b1af93acb7c41dc063559964ff27", + ".kilo/skills/aidd-context/03-context-generate/assets/skills/skill-template.md": "f30bc5a47574cc85dc0daddfa1abd0d7f8bd8a39e201fbb3df5784506a61b13a", + ".kilo/skills/aidd-context/03-context-generate/assets/rules/rule-template.md": "200ec56d8ed43acd51b94df783f7de9236b00a6e5398a98a30640d02edb76ba1", + ".kilo/skills/aidd-context/03-context-generate/assets/agents/agent-template.md": "476802721ac4947d741b4bd17626860c4914037992a2ebf0ef282553f819aa25", + ".kilo/skills/aidd-context/03-context-generate/actions/skills/01-capture-intent.md": "850c9b7dab101eba66284950eb3b8b612aca3ad40a1b13fcdc5ac181d1f72069", + ".kilo/skills/aidd-context/03-context-generate/actions/skills/02-design-evals.md": "fc51fb2c85a7462fe27586ddc3cc5c2699214dcd059c28f7ab8daf26a6479ced", + ".kilo/skills/aidd-context/03-context-generate/actions/skills/03-decompose-actions.md": "8bf7ff1ac05d968fc7a3e9b6aa5de3df3be04b07e96517cbe06268baec4151fc", + ".kilo/skills/aidd-context/03-context-generate/actions/skills/04-draft-skill.md": "08946a56b720408bee9c63cd7141e6c869e6818610546f8a42628fe6d2e12514", + ".kilo/skills/aidd-context/03-context-generate/actions/skills/05-write-actions.md": "948ac436af5e9c9e897b8c4e44b12a278943bff2b93afc10b4ddaee587d58cc5", + ".kilo/skills/aidd-context/03-context-generate/actions/skills/06-validate.md": "658c1077b03c461bb0c3fa17db047e4d2fbc3874f4d4fbd2e3dc5df606aa03ee", + ".kilo/skills/aidd-context/03-context-generate/actions/rules/01-generate-rules.md": "855b9adf65b0b0cc31ca15c68b8ac37da6a816c9a6416fbc3aea424cffa238c3", + ".kilo/skills/aidd-context/03-context-generate/actions/agents/01-generate-agent.md": "3dce30335eba1d60c5a62d43184d133eed621bbc344e9277ab0f05ead443e0ba", + ".kilo/skills/aidd-context/02-project-init/SKILL.md": "a0d80b727082d5d8c992cce9fd3ae9921cc0479c98ca2fbdf105bd414504a3e8", + ".kilo/skills/aidd-context/02-project-init/references/mapping-ai-context-file.md": "cf251454634037d4affd1d27cee2593c555dfe29305b521d3bed4564f4cad273", + ".kilo/skills/aidd-context/02-project-init/assets/AGENTS.md": "8b3d349220e9f96b45dedf316eeb2b3418666d96d0a4f64d315cadfc9ef337ae", + ".kilo/skills/aidd-context/02-project-init/assets/GUIDELINES.md": "bf41995402b46268ba53ceec41be00c455004603f69035349b6ff3bef14cc18a", + ".kilo/skills/aidd-context/02-project-init/assets/README.md": "575086cda27a386806a28c1bb4865cb5f974dc3fe6e39ca8bdd2c19764cf2059", + ".kilo/skills/aidd-context/02-project-init/assets/golden-principles.md": "e83c582a9e1477a42531734deed90061f4e004ea28e1b1616bf714070856c888", + ".kilo/skills/aidd-context/02-project-init/assets/templates/workflows/README.md": "33d50760dfde22feb8878cf3e24d399a748eef8ed2df630f465d673d9f125a53", + ".kilo/skills/aidd-context/02-project-init/assets/templates/stack/README.md": "8dbccfc7d47424411b7725374247b50973a962fff777b272a23109b7e0f2a099", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/README.md": "66e6275cd7e7187ca520ad8f2f3c57ebecf967742901e00085c139fd9eea5892", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/architecture.md": "f00c02db4135a43e99f5bdd7888f40f290e39dd4a65bb1539eca9a06c9873427", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/codebase-map.md": "5d1d184bc70c93e0073cb64312f4fa8d50fcf465b0da982f37bde25d624a5386", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/coding-assertions.md": "2e3d7b16f85e8af3cf9971c3b0c23409653e8d693c969ff11892c5a4f10bea90", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/deployment.md": "361e80ebbddda1f0497856530e7a5a35cd2c8ae56df2a6ef64268657de333d28", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/project-brief.md": "0240ee41e05c6091bd136b7c3b18f603731128941c987449917886b191e38e90", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/testing.md": "760e58855270ee9210b20987b3f01b537b25f6108f8d164e2a88000f4cb278b7", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/vcs.md": "3eccc51898b8c0c17245a737c9fa540f30706676ea01ff771d6898f3861060f1", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/frontend/browsing.md": "f8ab234ba45571979a5dbc2181859a8f6859cd56c9d86371560516b3323dd8a1", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/frontend/design.md": "edcaa8ab125acd64d330a32abac102fa3d0b37b45ef03a9552c71c885c1ea847", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/frontend/forms.md": "662dd1b62a9d3fef587cf38f1c2f6fe3de2d0cb226a9ec50dd7b576bf824115b", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/backend/api-docs.md": "09b9633c1d8c3b5a11bf0d171abf148bf159836ae96223a78a52b9e56ffb52e8", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/backend/backend-communication.md": "87d548e12bed0fa2a2d7601fe1c36d810f40051abd713cc3d4462582319babde", + ".kilo/skills/aidd-context/02-project-init/assets/templates/memory/backend/database.md": "899ceb107598c8b9a1adc3ff7cf8b6217e4582553f5c27f3e22b9e7fb0f555b9", + ".kilo/skills/aidd-context/02-project-init/actions/01-init-context-file.md": "33c907084418ec22f8c22381fbefecfbf3d88c250781c852059bf611be0f2ae2", + ".kilo/skills/aidd-context/02-project-init/actions/02-scaffold-docs.md": "c07f175692241d477b029441a2a984c2fff6de0041ff6f47a6851d73900a1ac3", + ".kilo/skills/aidd-context/02-project-init/actions/03-generate-memory.md": "f7cde767985a1d7ca8c2d74c1ffcc11eac2232a97e26b46573533eefcdd5ac96", + ".kilo/skills/aidd-context/02-project-init/actions/04-review-memory.md": "211ad0ad0d7c4925acbd8a41f775a16b09de51b85d9f3fc6f972170ac63fd353", + ".kilo/skills/aidd-context/02-project-init/actions/05-init-rules-skeleton.md": "12924f4535c083e114a5a4a4ef148e4aa2c8c4c436d23c8f50e152f869e3be2c", + ".kilo/skills/aidd-context/02-project-init/actions/06-sync-memory.md": "3f6825c8d230cea72f729aee15cf6136b613b7a9b07ddec237b91fe5ad3be59b", + ".kilo/skills/aidd-context/01-bootstrap/SKILL.md": "3f4dd3a7b87d16ad35fdd3a671c5cd27f6fb55e8a0131b46f667e5a43ecb3308", + ".kilo/skills/aidd-context/01-bootstrap/references/stack-heuristics.md": "17f7c0df7b19090f42c26ddf70df1049897b3973d9c1bf8df358b87a1cc9985a", + ".kilo/skills/aidd-context/01-bootstrap/evals/scenarios.json": "c5998a91c584d561618dbabe66f21bd8f446260731778de4f467a70a6da21784", + ".kilo/skills/aidd-context/01-bootstrap/assets/checklist.md": "64b84a7712ca78bc1901d2c78c183310336a4abe1b5890a689c3cf166b026d21", + ".kilo/skills/aidd-context/01-bootstrap/assets/install-template.md": "cd67115a7b11fddea0258810b9cee1d35c3e12840f515c5364fa769cad9d6a60", + ".kilo/skills/aidd-context/01-bootstrap/actions/01-gather-needs.md": "b3251034285434b06775ab4a59ffe27259bb23cb97ac65927c946a4846ea946d", + ".kilo/skills/aidd-context/01-bootstrap/actions/02-propose-candidates.md": "57376071d9d0b011c5f19ba218a3ef28c3bf1fba1220a87260806a2d79e341d6", + ".kilo/skills/aidd-context/01-bootstrap/actions/03-audit-candidates.md": "def30caedeca0babb4500b2081fe46bdcaf72ee911e4ad94ae860b068c2ee0e0", + ".kilo/skills/aidd-context/01-bootstrap/actions/04-pick-and-design.md": "1444ede4a16e6df368792d7f214e0bb61912621a9c266205e031ec73151606e7", + ".kilo/skills/aidd-context/01-bootstrap/actions/05-write-install-md.md": "c94afb152683cc29f10d2d4451632265b7fdc763a8a41b69f8ddc2c00b81dfd0", + ".kilo/skills/aidd-async-dev/03-review/SKILL.md": "495817892fff18cd760e7005b493b8de61049c9dbd71596edf136e3a434b2d4b", + ".kilo/skills/aidd-async-dev/03-review/references/stop-conditions.md": "47280ec7ebb0bd2d25bb7bf7dd716f5ef41a0ad8163653fa139981b5283e7ba0", + ".kilo/skills/aidd-async-dev/03-review/evals/scenarios.json": "562d462eedcb589487585a6840d3077a8cce760ea4ecd5e416a8a01b8472d995", + ".kilo/skills/aidd-async-dev/03-review/actions/skills/01-collect-comments.md": "a7494fcd9ec705cf76d36ed9b7ce59b2d9ed6e82ce64084e2ed935d52ab57527", + ".kilo/skills/aidd-async-dev/03-review/actions/skills/02-detect-stop.md": "b5d40992028d2e0a1d59f31dd699237005ebb51d9c70a648aca7f757f7449659", + ".kilo/skills/aidd-async-dev/03-review/actions/skills/03-fix-iteration.md": "a3133409542ed6fc8e9b814658be68504e646cc44ed58ca749251c6b6dd45f25", + ".kilo/skills/aidd-async-dev/03-review/actions/skills/04-finalize.md": "bf01c103e3940fda6fd07092bd35f44993f24cdd25ec7ff5c52004b20a37cb17", + ".kilo/skills/aidd-async-dev/02-run/SKILL.md": "6d82a4799255b30a52e691817e214b12c22547ebe0796547d036df474b6a41c0", + ".kilo/skills/aidd-async-dev/02-run/evals/scenarios.json": "cf78e4c46ac988912a7de98b1a1143c439d3c744bef234d7294f17b4836a84d0", + ".kilo/skills/aidd-async-dev/02-run/actions/skills/01-poll-ready.md": "38b0767d080c60149fc4dd1b04395c72b6cda204bd2152d2f9be54db814626fd", + ".kilo/skills/aidd-async-dev/02-run/actions/skills/02-resolve-deps.md": "9ede89ff3396287bde2ed9fed6e8068a8411b9190dc446ec392badaf31a52a5f", + ".kilo/skills/aidd-async-dev/02-run/actions/skills/03-acquire-lock.md": "0a9b54f4bc9ebdb653932b57bd9d61e9ee7248d86104bc95841617beb4eeeee5", + ".kilo/skills/aidd-async-dev/02-run/actions/skills/04-check-sdlc.md": "1934b9f0fb5824736349070ceca67105892ef7e4f319ca95ae955f564e821633", + ".kilo/skills/aidd-async-dev/02-run/actions/skills/05-delegate-sdlc.md": "3142a7d2ee15d1871fecc1b4977ff3fb43b9a343f25913a54cc943d9fab31a7c", + ".kilo/skills/aidd-async-dev/02-run/actions/skills/06-write-audit.md": "3cfbabf8f6c6e267994afcf189d17234eac4c3bb35ab68408776f1acb644b6ba", + ".kilo/skills/aidd-async-dev/02-run/actions/skills/07-emit-webhook.md": "4c8fe76bdf265386247a636dafc9365946114afa54c479ef13abfd929d7af3db", + ".kilo/skills/aidd-async-dev/01-setup/SKILL.md": "bb3a30cd1e18d37751cd31e9d72d82cfb755d9787085485d2f6052aa1c3742bf", + ".kilo/skills/aidd-async-dev/01-setup/references/auth-modes.md": "4122f895e4b210fce0e8f4cac81a79f6711eca56edac02ac43235359a77c2f62", + ".kilo/skills/aidd-async-dev/01-setup/evals/scenarios.json": "79b49984a0999a3f34348bedbf8e7fe815ebdddf825701fabfedbe39f7a1ede2", + ".kilo/skills/aidd-async-dev/01-setup/assets/config-template.json": "d47d1d93014157d525f48f3c96626472d940fafffc0deb106af2371db97b4e83", + ".kilo/skills/aidd-async-dev/01-setup/assets/workflow-template.yml": "63be55459862cf9c1f9943ac48dde6f1304a0fd3c3231b608e2217f8ee785d3f", + ".kilo/skills/aidd-async-dev/01-setup/actions/skills/01-detect-context.md": "83f3ba3022dd75fa2597a872a55e8f75173762f932ed39ae73d8181c0b1b4536", + ".kilo/skills/aidd-async-dev/01-setup/actions/skills/02-ask-config.md": "986335d1e86a633d0083fe4488aae58266a82ec35d0cdb0b74f0bbcea70e6f4e", + ".kilo/skills/aidd-async-dev/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", + ".kilo/skills/aidd-async-dev/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", + ".kilo/skills/aidd-async-dev/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", + ".kilo/plugin/aidd-context-hooks.js": "135970897458426190377542adc66dea0d42eca4badf9161d671ff34a1275ede", + ".kilo/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", + ".kilo/agents/aidd-async-dev-async-orchestrator.md": "3eb709fb7da8f6df7d4d76c7c69fce0a00b596d7b9522b5dcf3898c7a94d91cf", + ".kilo/agents/aidd-dev-implementer.md": "4b4fe709e0ed56b097b697a49f6adfc200cccaad36a0e978a12e4f062a3a5e38", + ".kilo/agents/aidd-dev-planner.md": "c83648c34068b6fa762fdc6b073e31cb3e9dfe5bd5bf2fd7e2cb4ad3e865feee", + ".kilo/agents/aidd-dev-reviewer.md": "30e2dbb93309d23fc99cbce36a4fcfd0b6d76e49c3425979827727327ba697a6" } } diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index 0123934dd..c69798490 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -87,7 +87,7 @@ { "invocation": "aidd marketplace remove", "exitCode": 0, - "help": "Usage: aidd marketplace remove [options] \n\nRemove a registered plugin marketplace\n\nOptions:\n --yes Skip the orphan-cleanup prompt\n -h, --help display help for command" + "help": "Usage: aidd marketplace remove [options] \n\nRemove a registered plugin marketplace\n\nOptions:\n --yes Skip the orphan-cleanup prompt\n --scope Remove from project or user scope (default: \"project\")\n -h, --help display help for command" }, { "invocation": "aidd plugin", @@ -107,7 +107,7 @@ { "invocation": "aidd plugin remove", "exitCode": 0, - "help": "Usage: aidd plugin remove [options] \n\nRemove a plugin from one or all AI tools\n\nOptions:\n --tool Target AI tool (default: all installed)\n -h, --help display help for command" + "help": "Usage: aidd plugin remove [options] \n\nRemove a plugin from one or all AI tools\n\nOptions:\n --tool Target AI tool (default: all installed)\n --scope Removal scope (default: project)\n -h, --help display help for command" }, { "invocation": "aidd plugin search", @@ -117,7 +117,7 @@ { "invocation": "aidd plugin update", "exitCode": 0, - "help": "Usage: aidd plugin update [options] [name]\n\nUpdate one or all plugins for one or all AI tools\n\nOptions:\n --tool Target AI tool (default: all installed)\n -h, --help display help for command" + "help": "Usage: aidd plugin update [options] [name]\n\nUpdate one or all plugins for one or all AI tools\n\nOptions:\n --tool Target AI tool (default: all installed)\n --scope Update scope (default: project)\n -h, --help display help for command" }, { "invocation": "aidd setup", @@ -192,7 +192,7 @@ { "invocation": "aidd translate", "exitCode": 0, - "help": "Usage: aidd translate [options] \n\nConvert an arbitrary source into a target-native plugin tree — records nothing\n(see `sync` for the manifest-driven, tracked version)\n\nArguments:\n source Path to the source framework directory\n\nOptions:\n --to Conversion target (claude, cursor, copilot, codex,\n opencode)\n --out Output directory (marketplace dist or project root)\n --as Output layout (default: \"marketplace\")\n --force Overwrite existing files at canonical paths under\n --out\n -h, --help display help for command" + "help": "Usage: aidd translate [options] \n\nConvert an arbitrary source into a target-native plugin tree — records nothing\n(see `sync` for the manifest-driven, tracked version)\n\nArguments:\n source Path to the source framework directory\n\nOptions:\n --to Conversion target (claude, cursor, copilot, codex,\n opencode, kilo)\n --out Output directory (marketplace dist or project root)\n --as Output layout (default: \"marketplace\")\n --force Overwrite existing files at canonical paths under\n --out\n -h, --help display help for command" }, { "invocation": "aidd update", diff --git a/cli/tests/golden/snapshots/phase0/snapshot.json b/cli/tests/golden/snapshots/phase0/snapshot.json index e28a34f61..93c16f4cf 100644 --- a/cli/tests/golden/snapshots/phase0/snapshot.json +++ b/cli/tests/golden/snapshots/phase0/snapshot.json @@ -52,6 +52,7 @@ "cache/built//aidd-framework/claude/plugins/aidd-test/agents/code-reviewer.md", "cache/built//aidd-framework/claude/plugins/aidd-test/hooks/check.sh", "cache/built//aidd-framework/claude/plugins/aidd-test/hooks/hooks.json", + "cache/built//aidd-framework/claude/plugins/aidd-test/hooks/session-start.js", "cache/built//aidd-framework/claude/plugins/aidd-test/skills/commit/SKILL.md", "cache/built//aidd-framework/claude/plugins/aidd-test/skills/hello.md", "manifest.json", @@ -244,6 +245,7 @@ "filesWritten": [ ".cursor/hooks.json", ".cursor/hooks/aidd-test/check.sh", + ".cursor/hooks/aidd-test/session-start.js", ".cursor/settings.json" ], "manifest": { @@ -293,14 +295,26 @@ }, "version": "", "strict": true, - "files": { - "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", - "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", - "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", - "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", - "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" - }, + "files": {}, "scope": "user", + "projectHooks": { + "entries": [ + { + "event": "sessionStart", + "command": "node ./.cursor/hooks/aidd-test/session-start.js", + "digest": "effe04b6579d7b78876962e5b18998b6" + }, + { + "event": "preToolUse", + "command": "./.cursor/hooks/aidd-test/check.sh", + "digest": "a2f1a3d6f4ae24d6e7011f435e0f223c" + } + ], + "scripts": { + ".cursor/hooks/aidd-test/check.sh": "d3cb6c174a3ae1041a087fef46f9b70e", + ".cursor/hooks/aidd-test/session-start.js": "2bd5de8338cd14944f86eef1169bf716" + } + }, "marketplace": "aidd-framework" } ] @@ -311,7 +325,7 @@ { "command": "doctor", "exitCode": 0, - "stdout": "\nAI tools:\n claude (v): 1 files, 0 merge files\n cursor (v): 1 files, 0 merge files\n\nDrift:\nAI tools:\n claude (v): in sync\n cursor (v):\n + .cursor/hooks/aidd-test/check.sh\n + .cursor/hooks.json\n 0 modified, 0 deleted, 2 added\nIDE tools:\n (none installed)\nPlugins:\n (all in sync)\n\nAI:\n\nInstallation is healthy\n", + "stdout": "\nAI tools:\n claude (v): 1 files, 0 merge files\n cursor (v): 1 files, 0 merge files\n\nDrift:\nAI tools:\n claude (v): in sync\n cursor (v):\n + .cursor/hooks/aidd-test/check.sh\n + .cursor/hooks/aidd-test/session-start.js\n + .cursor/hooks.json\n 0 modified, 0 deleted, 3 added\nIDE tools:\n (none installed)\nPlugins:\n (all in sync)\n\nAI:\n\nInstallation is healthy\n", "stderr": "Warning: /.claude/plugins/installed_plugins.json could not be read — ENOENT\n Fix: The plugin does not load until claude's own CLI has run and answered this.\n", "filesWritten": [], "manifest": { @@ -361,14 +375,26 @@ }, "version": "", "strict": true, - "files": { - "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", - "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", - "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", - "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", - "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" - }, + "files": {}, "scope": "user", + "projectHooks": { + "entries": [ + { + "event": "sessionStart", + "command": "node ./.cursor/hooks/aidd-test/session-start.js", + "digest": "effe04b6579d7b78876962e5b18998b6" + }, + { + "event": "preToolUse", + "command": "./.cursor/hooks/aidd-test/check.sh", + "digest": "a2f1a3d6f4ae24d6e7011f435e0f223c" + } + ], + "scripts": { + ".cursor/hooks/aidd-test/check.sh": "d3cb6c174a3ae1041a087fef46f9b70e", + ".cursor/hooks/aidd-test/session-start.js": "2bd5de8338cd14944f86eef1169bf716" + } + }, "marketplace": "aidd-framework" } ] @@ -379,7 +405,7 @@ { "command": "doctor", "exitCode": 1, - "stdout": "\nAI tools:\n claude (v): 1 files, 0 merge files\n cursor (v): 1 files, 0 merge files\n\nDrift:\nAI tools:\n claude (v):\n ~ .claude/settings.json\n 1 modified, 0 deleted, 0 added\n cursor (v):\n + .cursor/hooks/aidd-test/check.sh\n + .cursor/hooks.json\n 0 modified, 0 deleted, 2 added\nIDE tools:\n (none installed)\nPlugins:\n (all in sync)\n\nAI:\n", + "stdout": "\nAI tools:\n claude (v): 1 files, 0 merge files\n cursor (v): 1 files, 0 merge files\n\nDrift:\nAI tools:\n claude (v):\n ~ .claude/settings.json\n 1 modified, 0 deleted, 0 added\n cursor (v):\n + .cursor/hooks/aidd-test/check.sh\n + .cursor/hooks/aidd-test/session-start.js\n + .cursor/hooks.json\n 0 modified, 0 deleted, 3 added\nIDE tools:\n (none installed)\nPlugins:\n (all in sync)\n\nAI:\n", "stderr": "Warning: /.claude/plugins/installed_plugins.json could not be read — ENOENT\n Fix: The plugin does not load until claude's own CLI has run and answered this.\nWarning: Modified tracked file: .claude/settings.json\n Fix: Run `aidd sync --force` to revert to the framework version.\n", "filesWritten": [], "manifest": { @@ -429,14 +455,26 @@ }, "version": "", "strict": true, - "files": { - "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", - "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", - "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", - "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", - "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" - }, + "files": {}, "scope": "user", + "projectHooks": { + "entries": [ + { + "event": "sessionStart", + "command": "node ./.cursor/hooks/aidd-test/session-start.js", + "digest": "effe04b6579d7b78876962e5b18998b6" + }, + { + "event": "preToolUse", + "command": "./.cursor/hooks/aidd-test/check.sh", + "digest": "a2f1a3d6f4ae24d6e7011f435e0f223c" + } + ], + "scripts": { + ".cursor/hooks/aidd-test/check.sh": "d3cb6c174a3ae1041a087fef46f9b70e", + ".cursor/hooks/aidd-test/session-start.js": "2bd5de8338cd14944f86eef1169bf716" + } + }, "marketplace": "aidd-framework" } ] @@ -496,15 +534,27 @@ "path": "/plugins/aidd-test" }, "version": "", - "strict": false, - "files": { - "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", - "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", - "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", - "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", - "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" - }, + "strict": true, + "files": {}, "scope": "user", + "projectHooks": { + "entries": [ + { + "event": "sessionStart", + "command": "node ./.cursor/hooks/aidd-test/session-start.js", + "digest": "effe04b6579d7b78876962e5b18998b6" + }, + { + "event": "preToolUse", + "command": "./.cursor/hooks/aidd-test/check.sh", + "digest": "a2f1a3d6f4ae24d6e7011f435e0f223c" + } + ], + "scripts": { + ".cursor/hooks/aidd-test/check.sh": "d3cb6c174a3ae1041a087fef46f9b70e", + ".cursor/hooks/aidd-test/session-start.js": "2bd5de8338cd14944f86eef1169bf716" + } + }, "marketplace": "aidd-framework" } ] @@ -515,7 +565,7 @@ { "command": "doctor", "exitCode": 0, - "stdout": "\nAI tools:\n claude (v): 1 files, 0 merge files\n cursor (v): 1 files, 0 merge files\n\nDrift:\nAI tools:\n claude (v): in sync\n cursor (v):\n + .cursor/hooks/aidd-test/check.sh\n + .cursor/hooks.json\n 0 modified, 0 deleted, 2 added\nIDE tools:\n (none installed)\nPlugins:\n (all in sync)\n\nAI:\n\nInstallation is healthy\n", + "stdout": "\nAI tools:\n claude (v): 1 files, 0 merge files\n cursor (v): 1 files, 0 merge files\n\nDrift:\nAI tools:\n claude (v): in sync\n cursor (v):\n + .cursor/hooks/aidd-test/check.sh\n + .cursor/hooks/aidd-test/session-start.js\n + .cursor/hooks.json\n 0 modified, 0 deleted, 3 added\nIDE tools:\n (none installed)\nPlugins:\n (all in sync)\n\nAI:\n\nInstallation is healthy\n", "stderr": "Warning: /.claude/plugins/installed_plugins.json could not be read — ENOENT\n Fix: The plugin does not load until claude's own CLI has run and answered this.\n", "filesWritten": [], "manifest": { @@ -564,15 +614,27 @@ "path": "/plugins/aidd-test" }, "version": "", - "strict": false, - "files": { - "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", - "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", - "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", - "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", - "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" - }, + "strict": true, + "files": {}, "scope": "user", + "projectHooks": { + "entries": [ + { + "event": "sessionStart", + "command": "node ./.cursor/hooks/aidd-test/session-start.js", + "digest": "effe04b6579d7b78876962e5b18998b6" + }, + { + "event": "preToolUse", + "command": "./.cursor/hooks/aidd-test/check.sh", + "digest": "a2f1a3d6f4ae24d6e7011f435e0f223c" + } + ], + "scripts": { + ".cursor/hooks/aidd-test/check.sh": "d3cb6c174a3ae1041a087fef46f9b70e", + ".cursor/hooks/aidd-test/session-start.js": "2bd5de8338cd14944f86eef1169bf716" + } + }, "marketplace": "aidd-framework" } ] @@ -584,7 +646,7 @@ "command": "plugin remove aidd-test", "exitCode": 0, "stdout": "Plugin 'aidd-test' removed.\n", - "stderr": "Warning: claude CLI not found on PATH — 'aidd-test@aidd-framework' was not uninstalled from claude's own plugin registry and may still be enabled there.\nWarning: claude CLI not found on PATH — skipping native plugin activation.\n", + "stderr": "Warning: claude CLI not found on PATH — 'aidd-test@aidd-framework' was not uninstalled from the host; its ref may remain enabled after local removal.\nWarning: claude CLI not found on PATH — skipping native plugin activation.\n", "filesWritten": [], "manifest": { "version": 8, @@ -629,6 +691,7 @@ "cache/built//aidd-framework/claude/plugins/aidd-test/agents/code-reviewer.md", "cache/built//aidd-framework/claude/plugins/aidd-test/hooks/check.sh", "cache/built//aidd-framework/claude/plugins/aidd-test/hooks/hooks.json", + "cache/built//aidd-framework/claude/plugins/aidd-test/hooks/session-start.js", "cache/built//aidd-framework/claude/plugins/aidd-test/skills/commit/SKILL.md", "cache/built//aidd-framework/claude/plugins/aidd-test/skills/hello.md", "cache/built//aidd-framework/cursor/.build-version", @@ -638,6 +701,7 @@ "cache/built//aidd-framework/cursor/plugins/aidd-test/agents/code-reviewer.md", "cache/built//aidd-framework/cursor/plugins/aidd-test/hooks/check.sh", "cache/built//aidd-framework/cursor/plugins/aidd-test/hooks/hooks.json", + "cache/built//aidd-framework/cursor/plugins/aidd-test/hooks/session-start.js", "cache/built//aidd-framework/cursor/plugins/aidd-test/skills/commit/SKILL.md", "cache/built//aidd-framework/cursor/plugins/aidd-test/skills/hello.md", "manifest.json", @@ -652,6 +716,50 @@ "version": "", "files": [], "mergeFiles": [] + }, + "cursor": { + "toolId": "cursor", + "version": "", + "files": [], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": true, + "files": { + "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", + "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", + "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", + "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", + "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" + }, + "scope": "user", + "projectHooks": { + "entries": [ + { + "event": "sessionStart", + "command": "node ./.cursor/hooks/aidd-test/session-start.js", + "digest": "effe04b6579d7b78876962e5b18998b6" + }, + { + "event": "preToolUse", + "command": "./.cursor/hooks/aidd-test/check.sh", + "digest": "a2f1a3d6f4ae24d6e7011f435e0f223c" + } + ], + "scripts": { + ".cursor/hooks/aidd-test/check.sh": "d3cb6c174a3ae1041a087fef46f9b70e", + ".cursor/hooks/aidd-test/session-start.js": "2bd5de8338cd14944f86eef1169bf716" + } + }, + "marketplace": "aidd-framework" + } + ] } } } @@ -692,7 +800,7 @@ "command": "[errors] framework install --tool not-a-tool", "exitCode": 1, "stdout": "", - "stderr": "Error: Unknown tool: not-a-tool. Valid tools: claude, cursor, copilot, opencode, codex, vscode\n", + "stderr": "Error: Unknown tool: not-a-tool. Valid tools: claude, cursor, copilot, opencode, kilo, codex, vscode\n", "filesWritten": [], "manifest": null }, diff --git a/cli/tests/helpers/ports/build-unit-deps.ts b/cli/tests/helpers/ports/build-unit-deps.ts index b6a741d30..266d86bf3 100644 --- a/cli/tests/helpers/ports/build-unit-deps.ts +++ b/cli/tests/helpers/ports/build-unit-deps.ts @@ -4,6 +4,7 @@ import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import "../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { PluginCatalogRepositoryAdapter } from "../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; @@ -49,6 +50,7 @@ export async function buildUnitDeps(_projectRoot: string) { const hasher = new DeterministicHasher(); const fs = new InMemoryFileAdapter({}, hasher); const manifestRepo = new InMemoryManifestRepository(); + const userManifestRepo = new InMemoryManifestRepository(); const logger = new CLIOutput(false); const assetProvider = new BundledAssetProviderAdapter(); const pluginFetcher = new FixturePluginFetcher(); @@ -92,6 +94,7 @@ export async function buildUnitDeps(_projectRoot: string) { hasher, fs, manifestRepo, + userManifestRepo, logger, assetProvider, pluginFetcher, diff --git a/cli/tests/helpers/ports/fake-native-marketplace-source-reader.ts b/cli/tests/helpers/ports/fake-native-marketplace-source-reader.ts new file mode 100644 index 000000000..5b212925e --- /dev/null +++ b/cli/tests/helpers/ports/fake-native-marketplace-source-reader.ts @@ -0,0 +1,40 @@ +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceReader, + NativeMarketplaceSourceReading, +} from "../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import type { FakeNativePluginActivator } from "./fake-native-plugin-activator.js"; + +/** A fake host listing, not an ownership oracle: initial host state and add-result names are explicit. */ +export class FakeNativeMarketplaceSourceReader implements NativeMarketplaceSourceReader { + private readonly entries: Map; + private seenAdds = 0; + + constructor( + private readonly activator: FakeNativePluginActivator, + private readonly kind: "registry" | "effective-list", + private readonly nameForAddedPath: (path: string) => string | undefined, + initial: ReadonlyMap + ) { + this.entries = new Map(initial); + } + + async read(projectRoot: string): Promise { + while (this.seenAdds < this.activator.addedMarketplaces.length) { + const path = this.activator.addedMarketplaces[this.seenAdds++]; + const name = this.nameForAddedPath(path); + if (name === undefined) + return { + location: `fake native host (cwd ${projectRoot})`, + unreadable: `no catalogue identity for added path ${path}`, + }; + this.entries.set( + name, + this.kind === "registry" + ? { kind: "registry", source: path } + : { kind: "effective-list", root: path, sourceType: "local", source: path } + ); + } + return { location: `fake native host (cwd ${projectRoot})`, entries: new Map(this.entries) }; + } +} diff --git a/cli/tests/helpers/ports/fake-native-plugin-activator.ts b/cli/tests/helpers/ports/fake-native-plugin-activator.ts index 93340770a..fca466ea8 100644 --- a/cli/tests/helpers/ports/fake-native-plugin-activator.ts +++ b/cli/tests/helpers/ports/fake-native-plugin-activator.ts @@ -11,6 +11,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { readonly forcedRemovals: boolean[] = []; readonly enabledPlugins: string[] = []; readonly uninstalledPlugins: string[] = []; + readonly updatedPlugins: string[] = []; /** The scope each call actually carried, in call order, never guessed from the ref. */ readonly enabledPluginScopes: MarketplaceScope[] = []; readonly uninstalledPluginScopes: MarketplaceScope[] = []; @@ -21,6 +22,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { private readonly pluginsEnabledHere: boolean; private readonly state: "live" | "dead" | "unknown"; private readonly failOnUninstall: ReadonlySet; + private readonly failOnUpdate: ReadonlySet; private readonly crashOnAddMarketplace: boolean; private readonly crashOnUninstall: boolean; private readonly installedAtScope: ReadonlyMap; @@ -36,6 +38,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { /** What the tool answers about a name already registered. */ registrationState?: "live" | "dead" | "unknown"; failOnUninstall?: readonly string[]; + failOnUpdate?: readonly string[]; crashOnAddMarketplace?: boolean; crashOnUninstall?: boolean; installedAtScope?: ReadonlyMap; @@ -48,6 +51,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { this.pluginsEnabledHere = options.enablesPlugins ?? true; this.state = options.registrationState ?? "unknown"; this.failOnUninstall = new Set(options.failOnUninstall ?? []); + this.failOnUpdate = new Set(options.failOnUpdate ?? []); this.crashOnAddMarketplace = options.crashOnAddMarketplace ?? false; this.crashOnUninstall = options.crashOnUninstall ?? false; this.installedAtScope = options.installedAtScope ?? new Map(); @@ -115,4 +119,10 @@ export class FakeNativePluginActivator implements NativePluginActivator { } this.uninstalledPlugins.push(pluginRef); } + + updatePlugin(pluginRef: string): void { + if (this.failOnUpdate.has(pluginRef)) + throw new NativePluginCliError(`plugin update '${pluginRef}' failed`); + this.updatedPlugins.push(pluginRef); + } } diff --git a/cli/tests/helpers/ports/faulting-file-adapter.ts b/cli/tests/helpers/ports/faulting-file-adapter.ts index 6dafc14e7..a6f6765d0 100644 --- a/cli/tests/helpers/ports/faulting-file-adapter.ts +++ b/cli/tests/helpers/ports/faulting-file-adapter.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { InMemoryFileAdapter } from "./in-memory-file-adapter.js"; type FaultableMethod = "readFile" | "realpath" | "listDirectory" | "deleteDirectory"; @@ -40,5 +41,6 @@ export function errnoError(code: string): NodeJS.ErrnoException { } function faultKey(method: FaultableMethod, path: string): string { - return `${method}:${path.replaceAll("\\", "/")}`; + const canonicalPath = method === "realpath" ? resolve(path) : path; + return `${method}:${canonicalPath.replaceAll("\\", "/")}`; } diff --git a/cli/tests/helpers/ports/in-memory-file-adapter.ts b/cli/tests/helpers/ports/in-memory-file-adapter.ts index ab2c8d5d2..c9b8643d2 100644 --- a/cli/tests/helpers/ports/in-memory-file-adapter.ts +++ b/cli/tests/helpers/ports/in-memory-file-adapter.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { resolve } from "node:path"; import type { FileMerger } from "../../../src/contexts/tools/domain/ports/file-merger.js"; import { FileHash } from "../../../src/kernel/file.js"; import { @@ -99,7 +100,7 @@ export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { /** Resolves through the longest matching registered symlink, like a real `fs.realpath` * walking a symlinked ancestor; identity when none was declared for the path. */ async realpath(path: string): Promise { - const key = norm(path); + const key = norm(resolve(path)); let bestMatch: { link: string; target: string } | undefined; for (const [link, target] of this.symlinks) { if (key === link || key.startsWith(`${link}/`)) { @@ -108,14 +109,14 @@ export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { } } } - if (bestMatch === undefined) return key; + if (bestMatch === undefined) return norm(path); return bestMatch.target + key.slice(bestMatch.link.length); } /** Test-only: declares that `path` is a symlink resolving to `target`, so a test can * prove `realpath`-based containment without touching a real filesystem. */ setSymlink(path: string, target: string): void { - this.symlinks.set(norm(path), norm(target)); + this.symlinks.set(norm(resolve(path)), norm(target)); } async mergeJsonFile(path: string, content: string, strategy: MergeStrategy): Promise { diff --git a/cli/tests/helpers/ports/in-memory-telemetry-sink.ts b/cli/tests/helpers/ports/in-memory-telemetry-sink.ts index a4c2b2f1c..2b6c00d78 100644 --- a/cli/tests/helpers/ports/in-memory-telemetry-sink.ts +++ b/cli/tests/helpers/ports/in-memory-telemetry-sink.ts @@ -52,7 +52,10 @@ export class InMemoryTelemetrySink implements TelemetrySink { this.deletedFiles.push(fileName); } + readonly vendorsRead: string[] = []; + async readRecordsForVendor(vendorId: string): Promise { + this.vendorsRead.push(vendorId); return [...this.files.values()].flat().filter((record) => record.vendor_id === vendorId); } diff --git a/cli/tests/helpers/ports/realpath-windows.unit.test.ts b/cli/tests/helpers/ports/realpath-windows.unit.test.ts new file mode 100644 index 000000000..f7a21b909 --- /dev/null +++ b/cli/tests/helpers/ports/realpath-windows.unit.test.ts @@ -0,0 +1,35 @@ +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { errnoError, FaultingFileAdapter } from "./faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "./in-memory-file-adapter.js"; + +vi.mock("node:path", async (importOriginal) => { + const path = await importOriginal(); + return { + ...path, + resolve: (...segments: string[]) => path.win32.resolve("C:\\workspace", ...segments), + }; +}); + +describe("Windows realpath fixtures", () => { + it("resolves a rooted fixture's symlink after the caller adds the current drive", async () => { + const fs = new InMemoryFileAdapter(); + fs.setSymlink("/project/link", "/foreign"); + + expect(await fs.realpath(resolve("/project/link/file"))).toBe("/foreign/file"); + }); + + it("keeps unsymlinked virtual path identities stable for seeded project claims", async () => { + const fs = new InMemoryFileAdapter(); + + expect(await fs.realpath("/project/file")).toBe("/project/file"); + }); + + it("preserves a registered realpath fault when the caller resolves the fixture path", async () => { + const fs = new FaultingFileAdapter(); + const error = errnoError("ENOENT"); + fs.failOn("realpath", "/project/missing", error); + + await expect(fs.realpath(resolve("/project/missing"))).rejects.toBe(error); + }); +}); diff --git a/cli/tests/helpers/throwaway-profile.ts b/cli/tests/helpers/throwaway-profile.ts new file mode 100644 index 000000000..5e8e2cba4 --- /dev/null +++ b/cli/tests/helpers/throwaway-profile.ts @@ -0,0 +1,14 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Set in the main process before any worker exists: a worker thread's own env never reaches `os.homedir()`, so only this leaves a mutant no real profile to write. */ +export function setup(): () => void { + const home = mkdtempSync(join(tmpdir(), "aidd-test-profile-")); + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.APPDATA = join(home, "AppData", "Roaming"); + for (const key of ["AIDD_USER_CONFIG_DIR", "AIDD_TELEMETRY_DIR", "XDG_CONFIG_HOME"]) + delete process.env[key]; + return () => rmSync(home, { recursive: true, force: true }); +} diff --git a/cli/tests/helpers/throwaway-profile.unit.test.ts b/cli/tests/helpers/throwaway-profile.unit.test.ts new file mode 100644 index 000000000..d35522bac --- /dev/null +++ b/cli/tests/helpers/throwaway-profile.unit.test.ts @@ -0,0 +1,18 @@ +import { homedir, tmpdir } from "node:os"; +import { describe, expect, it } from "vitest"; +import { resolveAiddConfigDir, resolveHomeDir } from "../../src/kernel/reading/home-dir.js"; +import { userConfigDir } from "../../src/runtime/user-config-dir.js"; + +describe("the profile a test run sees", () => { + it("is a home under the temp directory, whichever route resolves it", () => { + for (const resolved of [homedir(), resolveHomeDir(), resolveAiddConfigDir(), userConfigDir()]) { + expect(resolved.startsWith(tmpdir()), resolved).toBe(true); + } + }); + + it("carries no override that points outside that home", () => { + for (const key of ["AIDD_USER_CONFIG_DIR", "AIDD_TELEMETRY_DIR", "XDG_CONFIG_HOME"]) { + expect(process.env[key], key).toBeUndefined(); + } + }); +}); diff --git a/cli/tests/helpers/unloadable-file-as-failed-test.integration.test.ts b/cli/tests/helpers/unloadable-file-as-failed-test.integration.test.ts new file mode 100644 index 000000000..584eaf2dc --- /dev/null +++ b/cli/tests/helpers/unloadable-file-as-failed-test.integration.test.ts @@ -0,0 +1,37 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +const FIXTURE = fileURLToPath(new URL("../fixtures/unloadable-test-file/", import.meta.url)); +const VITEST = join( + dirname(createRequire(import.meta.url).resolve("vitest/package.json")), + "vitest.mjs" +); +const OUTSIDE_THIS_RUN = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("VITEST")) +); + +it("a real vitest run hands Stryker's collection one failed test for a file that failed to load", () => { + const collected = join(mkdtempSync(join(tmpdir(), "unloadable-test-file-")), "collected.json"); + spawnSync( + process.execPath, + [VITEST, "run", "--root", FIXTURE, "--config", join(FIXTURE, "vitest.config.ts")], + { + env: { ...OUTSIDE_THIS_RUN, COLLECTED: collected }, + encoding: "utf8", + } + ); + const seen: { file: string }[] = JSON.parse(readFileSync(collected, "utf8")); + expect(seen.sort((a, b) => a.file.localeCompare(b.file))).toStrictEqual([ + { + file: "broken.fixture-test.ts", + state: "fail", + error: "the module under test failed to load", + }, + { file: "loads.fixture-test.ts", state: "pass" }, + ]); +}); diff --git a/cli/tests/helpers/unloadable-file-as-failed-test.ts b/cli/tests/helpers/unloadable-file-as-failed-test.ts new file mode 100644 index 000000000..9ef80a1e0 --- /dev/null +++ b/cli/tests/helpers/unloadable-file-as-failed-test.ts @@ -0,0 +1,23 @@ +import type { RunnerTestCase, RunnerTestFile } from "vitest"; +import type { Reporter } from "vitest/reporters"; + +export class UnloadableFileAsFailedTest implements Reporter { + onFinished(files: RunnerTestFile[]): void { + for (const file of files) { + if (file.result?.state !== "fail" || file.tasks.length > 0) continue; + const loadFailure: Omit = { + type: "test", + id: `${file.id}_load`, + name: "the file loads", + mode: "run", + meta: {}, + file, + suite: file, + timeout: 0, + annotations: [], + result: { state: "fail", errors: file.result.errors ?? [] }, + }; + file.tasks.push(loadFailure as RunnerTestCase); + } + } +} diff --git a/cli/tests/helpers/unloadable-file-as-failed-test.unit.test.ts b/cli/tests/helpers/unloadable-file-as-failed-test.unit.test.ts new file mode 100644 index 000000000..c81715501 --- /dev/null +++ b/cli/tests/helpers/unloadable-file-as-failed-test.unit.test.ts @@ -0,0 +1,54 @@ +import type { RunnerTestFile } from "vitest"; +import { describe, expect, it } from "vitest"; +import mutation from "../../vitest.mutation.config.js"; +import { UnloadableFileAsFailedTest } from "./unloadable-file-as-failed-test.js"; + +const LOAD_ERROR = { + name: "Error", + message: "SkillsCapability requires either prefix or directory", +}; + +function testFile(state: "pass" | "fail", tasks: RunnerTestFile["tasks"] = []): RunnerTestFile { + const file: RunnerTestFile = { + id: "cursor", + name: "cursor.unit.test.ts", + mode: "run", + meta: {}, + type: "suite", + filepath: "/repo/cli/tests/contexts/tools/domain/profiles/cursor.unit.test.ts", + projectName: "unit", + tasks, + result: { state, errors: state === "fail" ? [LOAD_ERROR] : [] }, + get file() { + return file; + }, + }; + return file; +} + +describe("a test file that fails to load, in a mutation run", () => { + it("counts as one failed test carrying the load error", () => { + const unloaded = testFile("fail"); + new UnloadableFileAsFailedTest().onFinished([unloaded]); + expect(unloaded.tasks).toHaveLength(1); + expect(unloaded.tasks[0]).toMatchObject({ + type: "test", + mode: "run", + file: unloaded, + suite: unloaded, + result: { state: "fail", errors: [LOAD_ERROR] }, + }); + }); + + it("leaves a file that loaded as it is, whether its tests passed or failed", () => { + const passed = testFile("pass"); + const failed = testFile("fail", [{ ...testFile("fail"), id: "inner" }]); + new UnloadableFileAsFailedTest().onFinished([passed, failed]); + expect(passed.tasks).toHaveLength(0); + expect(failed.tasks.map((task) => task.id)).toStrictEqual(["inner"]); + }); + + it("is a reporter of every mutation run", () => { + expect(mutation.test?.reporters).toContainEqual(expect.any(UnloadableFileAsFailedTest)); + }); +}); diff --git a/cli/tests/kernel/describe-error.unit.test.ts b/cli/tests/kernel/describe-error.unit.test.ts new file mode 100644 index 000000000..360ff52db --- /dev/null +++ b/cli/tests/kernel/describe-error.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { describeError, errorMessage } from "../../src/kernel/describe-error.js"; + +describe("describeError", () => { + it("answers the code of a filesystem failure", () => { + expect(describeError(Object.assign(new Error("open failed"), { code: "ENOENT" }))).toBe( + "ENOENT" + ); + }); + + it("answers the message when the code is not a string", () => { + expect(describeError(Object.assign(new Error("open failed"), { code: 2 }))).toBe("open failed"); + }); + + it("answers the message of an Error carrying no code", () => { + expect(describeError(new SyntaxError("Unexpected token"))).toBe("Unexpected token"); + }); + + it("stringifies a thrown value that is not an Error, whatever fields it carries", () => { + expect(describeError({ code: "ENOENT" })).toBe("[object Object]"); + }); +}); + +describe("errorMessage", () => { + it("answers an Error's message", () => { + expect(errorMessage(new Error("bad json"))).toBe("bad json"); + }); + + it("stringifies a thrown value that is not an Error", () => { + expect(errorMessage(42)).toBe("42"); + }); +}); diff --git a/cli/tests/kernel/errors.unit.test.ts b/cli/tests/kernel/errors.unit.test.ts index 4f07f5756..08e52e368 100644 --- a/cli/tests/kernel/errors.unit.test.ts +++ b/cli/tests/kernel/errors.unit.test.ts @@ -1,20 +1,9 @@ import { describe, expect, it } from "vitest"; -import { - AiddFilesDetectedError, - AlreadyInitializedError, - AuthStorageError, - FlatTargetExistsError, - HttpRedirectError, - InputRequiredError, - JsonParseError, - NoManifestError, - OutDirNotDirectoryError, - ToolNotInstalledError, -} from "../../src/kernel/errors.js"; +import * as errors from "../../src/kernel/errors.js"; describe("NoManifestError", () => { it("includes aidd setup hint in message", () => { - const error = new NoManifestError(); + const error = new errors.NoManifestError(); expect(error.message).toContain("aidd setup"); expect(error.name).toBe("NoManifestError"); }); @@ -22,7 +11,7 @@ describe("NoManifestError", () => { describe("AiddFilesDetectedError", () => { it("includes setup hint in message", () => { - const error = new AiddFilesDetectedError(); + const error = new errors.AiddFilesDetectedError(); expect(error.message).toContain("AIDD files detected but no manifest found"); expect(error.message).toContain("aidd setup"); expect(error.name).toBe("AiddFilesDetectedError"); @@ -31,7 +20,7 @@ describe("AiddFilesDetectedError", () => { describe("FlatTargetExistsError", () => { it("has correct error name", () => { - const error = new FlatTargetExistsError( + const error = new errors.FlatTargetExistsError( "/out/.github/agents/my-plugin/foo.agent.md", "my-plugin" ); @@ -39,7 +28,7 @@ describe("FlatTargetExistsError", () => { }); it("includes the conflicting path in the message", () => { - const error = new FlatTargetExistsError( + const error = new errors.FlatTargetExistsError( "/out/.github/agents/my-plugin/foo.agent.md", "my-plugin" ); @@ -47,7 +36,7 @@ describe("FlatTargetExistsError", () => { }); it("includes the plugin name in the message", () => { - const error = new FlatTargetExistsError( + const error = new errors.FlatTargetExistsError( "/out/.github/agents/my-plugin/foo.agent.md", "my-plugin" ); @@ -55,7 +44,7 @@ describe("FlatTargetExistsError", () => { }); it("mentions --force hint in message", () => { - const error = new FlatTargetExistsError( + const error = new errors.FlatTargetExistsError( "/out/.github/agents/my-plugin/foo.agent.md", "my-plugin" ); @@ -65,17 +54,17 @@ describe("FlatTargetExistsError", () => { describe("OutDirNotDirectoryError", () => { it("has correct error name", () => { - const error = new OutDirNotDirectoryError("/tmp/some-out"); + const error = new errors.OutDirNotDirectoryError("/tmp/some-out"); expect(error.name).toBe("OutDirNotDirectoryError"); }); it("includes the outDir path in the message", () => { - const error = new OutDirNotDirectoryError("/tmp/some-out"); + const error = new errors.OutDirNotDirectoryError("/tmp/some-out"); expect(error.message).toContain("/tmp/some-out"); }); it("does not mention source directory in the message", () => { - const error = new OutDirNotDirectoryError("/tmp/some-out"); + const error = new errors.OutDirNotDirectoryError("/tmp/some-out"); expect(error.message).not.toContain("--source"); expect(error.message).toContain("not a directory"); }); @@ -83,20 +72,20 @@ describe("OutDirNotDirectoryError", () => { describe("AlreadyInitializedError", () => { it("has default message when no argument provided", () => { - const error = new AlreadyInitializedError(); + const error = new errors.AlreadyInitializedError(); expect(error.name).toBe("AlreadyInitializedError"); expect(error.message).toContain("Already initialized"); }); it("uses provided message when given", () => { - const error = new AlreadyInitializedError("Custom message here."); + const error = new errors.AlreadyInitializedError("Custom message here."); expect(error.message).toBe("Custom message here."); }); }); describe("InputRequiredError", () => { it("carries the provided message", () => { - const error = new InputRequiredError("Prompt answer is required."); + const error = new errors.InputRequiredError("Prompt answer is required."); expect(error.name).toBe("InputRequiredError"); expect(error.message).toBe("Prompt answer is required."); }); @@ -104,13 +93,13 @@ describe("InputRequiredError", () => { describe("ToolNotInstalledError", () => { it("includes tool ID in message without context", () => { - const error = new ToolNotInstalledError("claude"); + const error = new errors.ToolNotInstalledError("claude"); expect(error.name).toBe("ToolNotInstalledError"); expect(error.message).toContain("claude"); }); it("includes context and tool ID when context is provided", () => { - const error = new ToolNotInstalledError("cursor", "The target tool"); + const error = new errors.ToolNotInstalledError("cursor", "The target tool"); expect(error.message).toContain("cursor"); expect(error.message).toContain("The target tool"); }); @@ -118,7 +107,7 @@ describe("ToolNotInstalledError", () => { describe("HttpRedirectError", () => { it("includes the URL in the message and sets error name", () => { - const error = new HttpRedirectError("https://example.com/redirect"); + const error = new errors.HttpRedirectError("https://example.com/redirect"); expect(error.name).toBe("HttpRedirectError"); expect(error.message).toContain("https://example.com/redirect"); expect(error.url).toBe("https://example.com/redirect"); @@ -127,7 +116,7 @@ describe("HttpRedirectError", () => { describe("JsonParseError", () => { it("includes the path and cause in the message", () => { - const error = new JsonParseError("/some/file.json", "Unexpected token"); + const error = new errors.JsonParseError("/some/file.json", "Unexpected token"); expect(error.name).toBe("JsonParseError"); expect(error.message).toContain("/some/file.json"); expect(error.message).toContain("Unexpected token"); @@ -136,8 +125,581 @@ describe("JsonParseError", () => { describe("AuthStorageError", () => { it("carries the provided message", () => { - const error = new AuthStorageError("Failed to write auth file"); + const error = new errors.AuthStorageError("Failed to write auth file"); expect(error.name).toBe("AuthStorageError"); expect(error.message).toBe("Failed to write auth file"); }); }); + +describe("every error in the catalog", () => { + const catalogue: readonly { + readonly build: () => Error; + readonly name: string; + readonly message: string; + }[] = [ + { + build: () => new errors.CapabilityConfigError("cap broke"), + name: "CapabilityConfigError", + message: "cap broke", + }, + { + build: () => new errors.CursorProjectScopeUnsupportedError(), + name: "CursorProjectScopeUnsupportedError", + message: + "Cursor plugins only support user-scope install (~/.cursor/plugins/local/). Project-scope is not auto-loaded by Cursor.", + }, + { + build: () => new errors.InvalidPluginScopeError("cursor", "project", "user"), + name: "InvalidPluginScopeError", + message: + "Tool 'cursor' does not support scope 'project'. Supported scope: 'user'. Re-run with --scope user or omit the flag.", + }, + { + build: () => new errors.AuthenticationError("gh"), + name: "AuthenticationError", + message: "Authentication failed (gh). Run `aidd auth login` to authenticate.", + }, + { + build: () => new errors.UpdateError(), + name: "UpdateError", + message: + "Update failed. If you saw a 403 error above, ensure your GitHub token includes both repo and read:packages scopes.\nUpdate your token at https://github.com/settings/tokens, then re-run `aidd auth login`.", + }, + { + build: () => new errors.ElevatedPermissionUpdateError("npm install -g x@latest"), + name: "ElevatedPermissionUpdateError", + message: [ + "Update failed: the global package directory is not writable (EPERM/EACCES).", + "Pick one:", + " 1. Run the terminal as Administrator (Windows) or with sudo (macOS/Linux), then re-run `aidd update`.", + " 2. Move global installs to a user-writable prefix, then re-run the update:", + " Windows: npm config set prefix %APPDATA%\\npm", + " macOS/Linux: npm config set prefix ~/.npm-global", + " 3. Run the update directly: npm install -g x@latest", + ].join("\n"), + }, + { + build: () => new errors.ManifestValidationError("bad manifest"), + name: "ManifestValidationError", + message: "bad manifest", + }, + { + build: () => new errors.McpConfigError("bad mcp"), + name: "McpConfigError", + message: "bad mcp", + }, + { + build: () => new errors.FrameworkResolutionError("no framework"), + name: "FrameworkResolutionError", + message: "no framework", + }, + { + build: () => new errors.CategoryMismatchError(["vscode"], "ai", ["claude", "cursor"]), + name: "CategoryMismatchError", + message: "vscode is not an AI tool. Valid AI tools: claude, cursor", + }, + { + build: () => new errors.CategoryMismatchError(["claude", "cursor"], "ide", ["vscode"]), + name: "CategoryMismatchError", + message: "claude, cursor are not IDE tools. Valid IDE tools: vscode", + }, + { + build: () => new errors.UnregisteredToolError("zed"), + name: "UnregisteredToolError", + message: "Tool 'zed' is not registered.", + }, + { + build: () => new errors.ToolNotInManifestError("zed"), + name: "ToolNotInManifestError", + message: "Tool 'zed' is not installed in the manifest.", + }, + { + build: () => new errors.InvalidManifestDataError("version missing"), + name: "InvalidManifestDataError", + message: "Invalid manifest data: version missing", + }, + { + build: () => new errors.InvalidManifestDataError(), + name: "InvalidManifestDataError", + message: "Invalid manifest data.", + }, + { + build: () => new errors.InvalidManifestToolIdError("zed"), + name: "InvalidManifestToolIdError", + message: "Invalid tool id in manifest: 'zed'.", + }, + { + build: () => new errors.InvalidMcpServerConfigError("db"), + name: "InvalidMcpServerConfigError", + message: 'MCP server "db" must have either a "command" or "url" field', + }, + { + build: () => new errors.OpencodeDualConfigError(), + name: "OpencodeDualConfigError", + message: "Both opencode.json and opencode.jsonc exist. Remove one.", + }, + { + build: () => new errors.PackageManagerDetectionError(["npm i -g x", "pnpm add -g x"]), + name: "PackageManagerDetectionError", + message: "Could not detect package manager. Run manually:\n npm i -g x\n pnpm add -g x", + }, + { + build: () => new errors.InvalidPluginSourceError("no kind"), + name: "InvalidPluginSourceError", + message: "Invalid plugin source: no kind", + }, + { + build: () => new errors.InvalidPluginSourceError(), + name: "InvalidPluginSourceError", + message: "Invalid plugin source.", + }, + { + build: () => new errors.InvalidPluginNameError("My Plugin"), + name: "InvalidPluginNameError", + message: + 'Invalid plugin name: "My Plugin". Use lowercase alphanumeric characters and hyphens only.', + }, + { + build: () => new errors.InvalidPluginVersionError("latest"), + name: "InvalidPluginVersionError", + message: 'Invalid plugin version: "latest". Expected semver format (e.g. 1.0.0).', + }, + { + build: () => new errors.MalformedPluginScopeError("aidd-dev", "global"), + name: "MalformedPluginScopeError", + message: + 'Plugin "aidd-dev" carries an invalid scope: "global". Expected "project" or "user".', + }, + { + build: () => new errors.UnresolvableUserScopeError("cursor"), + name: "UnresolvableUserScopeError", + message: + 'Manifest records a user-scope plugin for "cursor", but this tool\'s profile declares no user-scope plugins directory. Refusing to guess a base directory rather than silently resolving under the project root.', + }, + { + build: () => new errors.InvalidPluginManifestError("name missing"), + name: "InvalidPluginManifestError", + message: "Invalid plugin manifest: name missing", + }, + { + build: () => new errors.InvalidPluginManifestError(), + name: "InvalidPluginManifestError", + message: "Invalid plugin manifest.", + }, + { + build: () => new errors.MalformedMarketplaceCatalogError("/c.json", "not json", true), + name: "MalformedMarketplaceCatalogError", + message: + "Invalid plugin manifest: catalog at \"/c.json\" is malformed (not json). Run 'aidd marketplace refresh --force' to re-fetch a clean copy.", + }, + { + build: () => new errors.MalformedMarketplaceCatalogError("/c.json", "not json", false), + name: "MalformedMarketplaceCatalogError", + message: + 'Invalid plugin manifest: catalog at "/c.json" is malformed (not json). Fix or re-create the marketplace catalog file.', + }, + { + build: () => new errors.PluginNotFoundError("aidd-dev"), + name: "PluginNotFoundError", + message: "Plugin 'aidd-dev' is not installed.", + }, + { + build: () => new errors.DuplicatePluginError("aidd-dev"), + name: "DuplicatePluginError", + message: "Plugin 'aidd-dev' is already installed.", + }, + { + build: () => new errors.PluginFetchError("clone refused"), + name: "PluginFetchError", + message: "Failed to fetch plugin: clone refused", + }, + { + build: () => new errors.InvalidMarketplaceNameError("My Market"), + name: "InvalidMarketplaceNameError", + message: + 'Invalid marketplace name: "My Market". Use lowercase alphanumeric characters and hyphens only.', + }, + { + build: () => new errors.InvalidMarketplaceScopeError("global"), + name: "InvalidMarketplaceScopeError", + message: 'Invalid marketplace scope: "global". Expected "project" or "user".', + }, + { + build: () => new errors.MarketplaceAlreadyRegisteredError("aidd"), + name: "MarketplaceAlreadyRegisteredError", + message: "Marketplace 'aidd' is already registered.", + }, + { + build: () => new errors.MarketplaceNotFoundError("aidd"), + name: "MarketplaceNotFoundError", + message: "Marketplace 'aidd' is not registered.", + }, + { + build: () => new errors.TrustDeniedError("aidd"), + name: "TrustDeniedError", + message: "Trust denied for marketplace 'aidd'. Aborting.", + }, + { + build: () => new errors.PluginNotInMarketplaceError("aidd-dev"), + name: "PluginNotInMarketplaceError", + message: "Plugin 'aidd-dev' was not found in any registered marketplace.", + }, + { + build: () => new errors.VersionMismatchError("aidd-dev", "1.0.0", "2.0.0"), + name: "VersionMismatchError", + message: + "Plugin 'aidd-dev': requested version '1.0.0' does not match catalog version '2.0.0'.", + }, + { + build: () => new errors.AmbiguousPluginMatchError("aidd-dev", ["a", "b"]), + name: "AmbiguousPluginMatchError", + message: "Plugin 'aidd-dev' matches multiple marketplaces: a, b. Use --from .", + }, + { + build: () => new errors.NoMarketplacesRegisteredError(), + name: "NoMarketplacesRegisteredError", + message: "No marketplaces registered. Use `aidd marketplace add ` first.", + }, + { + build: () => new errors.UnreadableMarketplaceRegistryError("/r.json", "EACCES"), + name: "UnreadableMarketplaceRegistryError", + message: + "Cannot read the marketplace registry at /r.json: EACCES. Repair the file, or delete it to start from an empty registry.", + }, + { + build: () => new errors.UnreadableUserSourceReferencesError("/refs.json", "EACCES"), + name: "UnreadableUserSourceReferencesError", + message: + "Cannot read the shared-source reference registry at /refs.json: EACCES. Repair the file, or delete it to start from an empty registry.", + }, + { + build: () => new errors.InteractiveOnlyError("aidd setup"), + name: "InteractiveOnlyError", + message: "'aidd setup' requires an interactive terminal.", + }, + { + build: () => + new errors.SyncFailedError([ + { scope: "claude", message: "x" }, + { scope: "codex", message: "y" }, + ]), + name: "SyncFailedError", + message: "Sync failed for: claude, codex. See the warnings above.", + }, + { + build: () => new errors.CatalogFetchNotFoundError("https://x/c.json"), + name: "CatalogFetchNotFoundError", + message: "Catalog not found (HTTP 404): https://x/c.json", + }, + { + build: () => new errors.CatalogFetchAuthError("https://x/c.json"), + name: "CatalogFetchAuthError", + message: + 'Authentication required to fetch catalog from "https://x/c.json". Run `aidd auth login` first or use `--source local --path `.', + }, + { + build: () => new errors.CatalogFetchError("https://x/c.json", "timeout"), + name: "CatalogFetchError", + message: 'Failed to fetch catalog from "https://x/c.json": timeout', + }, + { + build: () => new errors.MissingPluginMetadataError(), + name: "MissingPluginMetadataError", + message: + "Cannot register github marketplace plugin: catalog entry is missing plugin metadata.", + }, + { + build: () => new errors.JsonSchemaValidationError(["a", "b"]), + name: "JsonSchemaValidationError", + message: "Manifest validation failed: a; b", + }, + { + build: () => new errors.FrameworkPlaceholderInPluginError("aidd-dev", "skills/x.md"), + name: "FrameworkPlaceholderInPluginError", + message: + "Framework placeholder '@{{TOOLS}}/' is not allowed inside plugin 'aidd-dev' (file: skills/x.md).", + }, + { + build: () => new errors.InvalidBuildPathsError("/src", "/src/out"), + name: "InvalidBuildPathsError", + message: + "Refusing to build: --out '/src/out' and --source '/src' must not contain each other.", + }, + { + build: () => new errors.InvalidSourceMarketplaceError("no plugins"), + name: "InvalidSourceMarketplaceError", + message: "Invalid source marketplace: no plugins.", + }, + { + build: () => new errors.OutDirNotDirectoryError("/out"), + name: "OutDirNotDirectoryError", + message: "Refusing to build: --out '/out' does not exist or is not a directory.", + }, + { + build: () => new errors.FlatTargetExistsError("/out/a.md", "aidd-dev"), + name: "FlatTargetExistsError", + message: + "Flat build conflict: '/out/a.md' already exists (plugin 'aidd-dev'). Re-run with --force to overwrite.", + }, + { + build: () => new errors.MarketplaceOutDirNotEmptyError("/out"), + name: "MarketplaceOutDirNotEmptyError", + message: + "Refusing to build: '/out' is not empty. Re-run with --force to overwrite files this build produces, or choose an empty --out directory.", + }, + { + build: () => new errors.UnknownToolCategoryError("editor"), + name: "UnknownToolCategoryError", + message: "Unknown category: editor", + }, + { + build: () => new errors.MarketplaceSourceKindError("remote"), + name: "MarketplaceSourceKindError", + message: "Not a remote source", + }, + { + build: () => new errors.MarketplaceSourceKindError("local"), + name: "MarketplaceSourceKindError", + message: "Not a local source", + }, + { + build: () => new errors.EmptyLocalSourcePathError(), + name: "EmptyLocalSourcePathError", + message: "Local source path must not be empty.", + }, + { + build: () => new errors.InvalidSetupToolIdError("zed", ["claude", "cursor"]), + name: "InvalidSetupToolIdError", + message: 'Invalid tool ID: "zed". Valid IDs: claude, cursor', + }, + { + build: () => new errors.UserScopeUnavailableError(), + name: "UserScopeUnavailableError", + message: + "--scope user is not wired for this command yet — no user-scope manifest repository was provided at construction.", + }, + { + build: () => new errors.UserScopeIdeToolsError(["vscode", "zed"]), + name: "UserScopeIdeToolsError", + message: + "--scope user installs no project files, so an IDE tool (vscode, zed) has nothing to install at user scope. Drop --ide, or run `aidd setup --ide ` separately at project scope.", + }, + { + build: () => new errors.UserScopeUnsupportedAiToolsError(["cursor", "opencode"]), + name: "UserScopeUnsupportedAiToolsError", + message: + "--scope user drives native activation machine-wide, and cursor, opencode declares no user-scope settings this CLI can point at. Drop it from --ai, or run `aidd setup --ai ` separately at project scope.", + }, + { + build: () => new errors.UserScopeNoToolsError(), + name: "UserScopeNoToolsError", + message: + "--scope user with no --ai registers the shared source for no tool at all. Pass `--ai ` naming which tool to activate machine-wide.", + }, + { + build: () => new errors.UserScopePluginModeError(), + name: "UserScopePluginModeError", + message: + "--scope user has no manifest entry a plugin can be recorded against yet, so --plugins has nothing to enable. Drop --plugins, or run `aidd plugin install` separately at project scope.", + }, + { + build: () => new errors.UserScopeFilterUnsupportedError("--plugin", "sync"), + name: "UserScopeFilterUnsupportedError", + message: + "--scope user tracks nothing --plugin can narrow — it names every requested tool, not one plugin or one file. Drop --plugin, or run `aidd sync` at project scope.", + }, + { + build: () => new errors.InvalidPluginModeConfigError("bad mode"), + name: "InvalidPluginModeConfigError", + message: "bad mode", + }, + { + build: () => new errors.InvalidInstallScopeError("global"), + name: "InvalidInstallScopeError", + message: "Invalid scope 'global'. Expected 'project' or 'user'.", + }, + { + build: () => new errors.UnknownAiToolIdError("zed", ["claude", "cursor"]), + name: "UnknownAiToolIdError", + message: "Unknown AI tool: zed. Valid AI tools: claude, cursor", + }, + { + build: () => new errors.NativePluginCliError("claude exited 2"), + name: "NativePluginCliError", + message: "claude exited 2", + }, + { + build: () => new errors.MarketplaceSourceConflictError("name taken"), + name: "MarketplaceSourceConflictError", + message: "name taken", + }, + { + build: () => new errors.UnreadableBuiltCatalogError("/built/c.json"), + name: "UnreadableBuiltCatalogError", + message: + "Cannot read the marketplace catalog this project just built, at /built/c.json — nothing was registered for it. Run `aidd sync` again once the source is fixed.", + }, + { + build: () => new errors.HttpError(500, "https://x"), + name: "HttpError", + message: "Unexpected HTTP 500 from https://x", + }, + { + build: () => new errors.HttpNotFoundError("https://x"), + name: "HttpNotFoundError", + message: "Resource not found (HTTP 404): https://x", + }, + { + build: () => new errors.HttpRedirectError("https://x"), + name: "HttpRedirectError", + message: "HTTP redirect without location header from https://x", + }, + { + build: () => new errors.JsonParseError("/f.json", "Unexpected token"), + name: "JsonParseError", + message: "Cannot parse existing JSON at /f.json: Unexpected token", + }, + { + build: () => new errors.AuthStorageError("no write"), + name: "AuthStorageError", + message: "no write", + }, + { + build: () => new errors.GhCliError("gh exited 1"), + name: "GhCliError", + message: "gh exited 1", + }, + { + build: () => new errors.AssetNotFoundError("schema.json"), + name: "AssetNotFoundError", + message: "Bundled asset not found: 'schema.json'", + }, + { + build: () => new errors.NoManifestError(), + name: "NoManifestError", + message: "No AIDD manifest found. Run `aidd setup` to initialize your project.", + }, + { + build: () => new errors.AiddFilesDetectedError(), + name: "AiddFilesDetectedError", + message: + "AIDD files detected but no manifest found.\nRun `aidd setup` to register existing files.", + }, + { + build: () => new errors.AlreadyInitializedError(), + name: "AlreadyInitializedError", + message: "Already initialized. Use `aidd update` to upgrade.", + }, + { + build: () => new errors.InputRequiredError("answer needed"), + name: "InputRequiredError", + message: "answer needed", + }, + { + build: () => new errors.ToolNotInstalledError("claude"), + name: "ToolNotInstalledError", + message: "claude is not installed", + }, + { + build: () => new errors.ToolNotInstalledError("cursor", "The target tool"), + name: "ToolNotInstalledError", + message: "The target tool 'cursor' is not installed.", + }, + { + build: () => new errors.UnknownTelemetrySinkSchemaVersionError(7), + name: "UnknownTelemetrySinkSchemaVersionError", + message: "Unknown telemetry sink schema version '7' — refusing to guess its shape.", + }, + { + build: () => new errors.OpencodeExportError("export timed out"), + name: "OpencodeExportError", + message: "export timed out", + }, + { + build: () => new errors.InvalidReportDayError("--from", "yesterday"), + name: "InvalidReportDayError", + message: "Invalid --from 'yesterday'. Expected a UTC day, as YYYY-MM-DD.", + }, + { + build: () => new errors.InvalidReportSpanError("0", 90), + name: "InvalidReportSpanError", + message: "Invalid --days '0'. Expected an integer between 1 and 90.", + }, + { + build: () => new errors.UnreadableIdentityFileError("/id.json", "EISDIR"), + name: "UnreadableIdentityFileError", + message: "Could not read the identity file at /id.json (EISDIR).", + }, + { + build: () => + new errors.TelemetryProjectScopeRequiresYesError("telemetry on", ".aidd/config.json"), + name: "TelemetryProjectScopeRequiresYesError", + message: + "telemetry on writes the git-tracked .aidd/config.json, turning telemetry on for everyone who clones. Pass --yes to confirm.", + }, + { + build: () => new errors.EmptyDisplayNameError(), + name: "EmptyDisplayNameError", + message: "`aidd telemetry identity use --name` needs a non-empty value.", + }, + { + build: () => new errors.IdentityRequiredToLinkError(), + name: "IdentityRequiredToLinkError", + message: "No identity to link onto yet. Run `aidd telemetry identity use` first.", + }, + { + build: () => new errors.EmptyIdentifierError("link"), + name: "EmptyIdentifierError", + message: "`aidd telemetry identity link` needs a non-empty value.", + }, + { + build: () => new errors.TelemetrySinkUnwritableError("/sink", new Error("EACCES")), + name: "TelemetrySinkUnwritableError", + message: "Telemetry sink directory is not writable: /sink (EACCES)", + }, + { + build: () => new errors.TelemetrySinkUnwritableError("/sink", "disk full"), + name: "TelemetrySinkUnwritableError", + message: "Telemetry sink directory is not writable: /sink (disk full)", + }, + { + build: () => new errors.IdentityWriteError("/id.json", new Error("ENOSPC")), + name: "IdentityWriteError", + message: "Could not write the identity file at /id.json (ENOSPC).", + }, + { + build: () => new errors.IdentityWriteError("/id.json", "EACCES", "remove"), + name: "IdentityWriteError", + message: "Could not remove the identity file at /id.json (EACCES).", + }, + ]; + + it.each(catalogue.map((entry) => [entry.name, entry] as const))( + "%s says exactly what went wrong", + (_name, entry) => { + const error = entry.build(); + expect({ name: error.name, message: error.message }).toStrictEqual({ + name: entry.name, + message: entry.message, + }); + } + ); + + it("keeps a malformed catalog an invalid plugin manifest, so existing catches still hold", () => { + expect(new errors.MalformedMarketplaceCatalogError("/c.json", "x", true)).toBeInstanceOf( + errors.InvalidPluginManifestError + ); + }); + + it("carries the status code and the URL an HTTP failure came from", () => { + const error = new errors.HttpError(503, "https://x/y"); + expect({ statusCode: error.statusCode, url: error.url }).toStrictEqual({ + statusCode: 503, + url: "https://x/y", + }); + }); + + it("carries the URL a 404 came from", () => { + expect(new errors.HttpNotFoundError("https://x/y").url).toBe("https://x/y"); + }); +}); diff --git a/cli/tests/kernel/file.unit.test.ts b/cli/tests/kernel/file.unit.test.ts new file mode 100644 index 000000000..3bd6b6787 --- /dev/null +++ b/cli/tests/kernel/file.unit.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { ManifestValidationError } from "../../src/kernel/errors.js"; +import { FileHash, InstallationFile, removeRedundantGitkeeps } from "../../src/kernel/file.js"; + +const HASH = new FileHash("d41d8cd98f00b204e9800998ecf8427e"); + +function file(relativePath: string): InstallationFile { + return new InstallationFile({ relativePath, content: "", hash: HASH }); +} + +function pathsOf(files: readonly InstallationFile[]): string[] { + return files.map((f) => f.relativePath); +} + +describe("FileHash", () => { + it("names the value it refused and the shape it wanted", () => { + expect(() => new FileHash("abc")).toThrow( + new ManifestValidationError('Invalid MD5 hash: "abc". Expected 32 lowercase hex characters.') + ); + }); +}); + +describe("InstallationFile", () => { + it("defaults to no merge strategy", () => { + expect(file("a.md").mergeStrategy).toBe("none"); + }); +}); + +describe("removeRedundantGitkeeps", () => { + it("drops a .gitkeep from a directory that holds another file", () => { + const kept = removeRedundantGitkeeps([file("dir/.gitkeep"), file("dir/a.md")]); + expect(pathsOf(kept)).toStrictEqual(["dir/a.md"]); + }); + + it("keeps a .gitkeep in a directory holding nothing else", () => { + const kept = removeRedundantGitkeeps([file("empty/.gitkeep"), file("other/a.md")]); + expect(pathsOf(kept)).toStrictEqual(["empty/.gitkeep", "other/a.md"]); + }); + + it("drops a .gitkeep two levels down when a sibling file sits beside it", () => { + const kept = removeRedundantGitkeeps([file("a/b/.gitkeep"), file("a/b/c.md")]); + expect(pathsOf(kept)).toStrictEqual(["a/b/c.md"]); + }); + + it("does not let a file in a subdirectory count for its parent", () => { + const kept = removeRedundantGitkeeps([file("dir/.gitkeep"), file("dir/sub/a.md")]); + expect(pathsOf(kept)).toStrictEqual(["dir/.gitkeep", "dir/sub/a.md"]); + }); + + it("does not let a file in the parent count for a subdirectory", () => { + const kept = removeRedundantGitkeeps([file("dir/sub/.gitkeep"), file("dir/a.md")]); + expect(pathsOf(kept)).toStrictEqual(["dir/sub/.gitkeep", "dir/a.md"]); + }); + + it("treats a file merely named like a gitkeep as an ordinary file", () => { + const kept = removeRedundantGitkeeps([file("dir/my.gitkeep"), file("dir/a.md")]); + expect(pathsOf(kept)).toStrictEqual(["dir/my.gitkeep", "dir/a.md"]); + }); + + it("returns the files it was given when none is a .gitkeep", () => { + const kept = removeRedundantGitkeeps([file("a.md"), file("dir/b.md")]); + expect(pathsOf(kept)).toStrictEqual(["a.md", "dir/b.md"]); + }); +}); diff --git a/cli/tests/kernel/markdown-yaml-like.unit.test.ts b/cli/tests/kernel/markdown-yaml-like.unit.test.ts new file mode 100644 index 000000000..27812aed6 --- /dev/null +++ b/cli/tests/kernel/markdown-yaml-like.unit.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { parseFrontmatter, serializeFrontmatter } from "../../src/kernel/markdown.js"; + +function frontmatterOf(lines: readonly string[]): Record { + return parseFrontmatter(`---\n${lines.join("\n")}\n---\nbody`).frontmatter; +} + +describe("parseFrontmatter, line by line", () => { + describe("a key", () => { + it("must start the line: an indented line under a scalar is ignored", () => { + expect(frontmatterOf(["parent: value", " child: v", " other:"])).toStrictEqual({ + parent: "value", + }); + }); + + it("may carry trailing spaces before its list", () => { + expect(frontmatterOf(["list: ", " - a"])).toStrictEqual({ list: ["a"] }); + }); + + it("reads a value with no space after the colon", () => { + expect(frontmatterOf(["key:value"])).toStrictEqual({ key: "value" }); + }); + + it("drops the spaces around a value", () => { + expect(frontmatterOf(["key: value "])).toStrictEqual({ key: "value" }); + }); + }); + + describe("a list", () => { + it("drops the spaces around an item", () => { + expect(frontmatterOf(["tags:", " - a "])).toStrictEqual({ tags: ["a"] }); + }); + + it("ends at the next key, even one whose value looks like an item", () => { + expect(frontmatterOf(["tags:", " - a", "note: keep - this"])).toStrictEqual({ + tags: ["a"], + note: "keep - this", + }); + }); + }); + + describe("a block scalar", () => { + it("folds > onto one line with single spaces", () => { + expect(frontmatterOf(["d: >", " one", " two", "next: x"])).toStrictEqual({ + d: "one two", + next: "x", + }); + }); + + it("folds >- the same way", () => { + expect(frontmatterOf(["d: >-", " one", " two"])).toStrictEqual({ d: "one two" }); + }); + + it("keeps | line by line", () => { + expect(frontmatterOf(["d: |", " one", " two"])).toStrictEqual({ d: "one\ntwo" }); + }); + + it("keeps |- line by line", () => { + expect(frontmatterOf(["d: |-", " one", " two"])).toStrictEqual({ d: "one\ntwo" }); + }); + + it("drops a blank line closing the block", () => { + expect(frontmatterOf(["d: >", " one", " "])).toStrictEqual({ d: "one" }); + }); + + it("drops a blank line closing a literal block", () => { + expect(frontmatterOf(["d: |", " one", " "])).toStrictEqual({ d: "one" }); + }); + }); + + describe("a scalar", () => { + it("reads ~ as null", () => { + expect(frontmatterOf(["v: ~"])).toStrictEqual({ v: null }); + }); + + it("keeps a number as text", () => { + expect(frontmatterOf(["count: 42"])).toStrictEqual({ count: "42" }); + }); + + it("keeps a bracketed value that is not JSON as text", () => { + expect(frontmatterOf(["tools: [a, b]"])).toStrictEqual({ tools: "[a, b]" }); + }); + + it("unquotes a single-quoted value and undoubles its apostrophes", () => { + expect(frontmatterOf(["name: 'it''s'"])).toStrictEqual({ name: "it's" }); + }); + + it("unquotes a double-quoted value and unescapes its quotes", () => { + expect(frontmatterOf(['name: "say \\"hi\\""'])).toStrictEqual({ name: 'say "hi"' }); + }); + + it("keeps a value that only ends with a quote", () => { + expect(frontmatterOf(["a: abc'", 'b: abc"'])).toStrictEqual({ a: "abc'", b: 'abc"' }); + }); + + it("keeps a value that only starts with a quote", () => { + expect(frontmatterOf(["a: 'abc", 'b: "abc'])).toStrictEqual({ a: "'abc", b: '"abc' }); + }); + + it("keeps a lone quote", () => { + expect(frontmatterOf(["a: '", 'b: "'])).toStrictEqual({ a: "'", b: '"' }); + }); + }); + + it("never reads the body as frontmatter", () => { + const { frontmatter, body } = parseFrontmatter("---\nname: x\n---\nfoo: bar\n"); + expect({ frontmatter, body }).toStrictEqual({ frontmatter: { name: "x" }, body: "foo: bar\n" }); + }); +}); + +describe("serializeFrontmatter, line by line", () => { + it("quotes a value that only starts with a bracket", () => { + expect(serializeFrontmatter({ a: "[open" }, "body")).toBe("---\na: '[open'\n---\nbody"); + }); + + it("quotes a value that only ends with a bracket", () => { + expect(serializeFrontmatter({ a: "closed]" }, "body")).toBe("---\na: 'closed]'\n---\nbody"); + }); + + it("drops only the leading newline of a bare body, never one inside it", () => { + expect(serializeFrontmatter({}, "a\nb")).toBe("a\nb"); + }); +}); diff --git a/cli/tests/kernel/materialization/flat-paths-hooks-prefix.unit.test.ts b/cli/tests/kernel/materialization/flat-paths-hooks-prefix.unit.test.ts new file mode 100644 index 000000000..2df83d66d --- /dev/null +++ b/cli/tests/kernel/materialization/flat-paths-hooks-prefix.unit.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { flatHooksPathWithLoaderEntry } from "../../../src/kernel/materialization/flat-paths.js"; + +describe("flatHooksPathWithLoaderEntry", () => { + it("strips only a leading hooks/ segment, never one deeper in the path", () => { + expect( + flatHooksPathWithLoaderEntry(".opencode/hooks/", null, "aidd-dev", "scripts/hooks/x.js") + ).toBe(".opencode/hooks/aidd-dev/scripts/hooks/x.js"); + }); +}); diff --git a/cli/tests/kernel/merge-content-empty.unit.test.ts b/cli/tests/kernel/merge-content-empty.unit.test.ts new file mode 100644 index 000000000..947f9d3c7 --- /dev/null +++ b/cli/tests/kernel/merge-content-empty.unit.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { isMergeContentEmpty } from "../../src/kernel/merge.js"; + +describe("isMergeContentEmpty", () => { + describe("at the top level", () => { + it("is empty for an object with no keys", () => { + expect(isMergeContentEmpty("{}", null)).toBe(true); + }); + + it("is not empty once any key remains", () => { + expect(isMergeContentEmpty('{"a":1}', null)).toBe(false); + }); + }); + + describe("under a section key", () => { + it("is empty when the section is the only key and holds nothing", () => { + expect(isMergeContentEmpty('{"mcpServers":{}}', "mcpServers")).toBe(true); + }); + + it("is empty when the section is absent and nothing else is there", () => { + expect(isMergeContentEmpty("{}", "mcpServers")).toBe(true); + }); + + it("is not empty when the section still holds an entry", () => { + expect(isMergeContentEmpty('{"mcpServers":{"a":{}}}', "mcpServers")).toBe(false); + }); + + it("is not empty when another key sits beside the section", () => { + expect(isMergeContentEmpty('{"mcpServers":{},"other":1}', "mcpServers")).toBe(false); + }); + }); + + it("is not empty when the content is not JSON", () => { + expect(isMergeContentEmpty("not json", null)).toBe(false); + }); +}); diff --git a/cli/tests/kernel/paths.unit.test.ts b/cli/tests/kernel/paths.unit.test.ts new file mode 100644 index 000000000..6911a0bf6 --- /dev/null +++ b/cli/tests/kernel/paths.unit.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + marketplaceCacheDir, + parseBuiltMarketplaceDir, + parseBuiltMarketplaceDirAtAnyRoot, + parseUserBuiltMarketplaceDir, + samePath, + samePathSegment, +} from "../../src/kernel/paths.js"; + +describe("marketplaceCacheDir()", () => { + it("nests the marketplace under the project's cache", () => { + expect(marketplaceCacheDir("/p", "mkt").replace(/\\/g, "/")).toBe( + "/p/.aidd/cache/marketplaces/mkt" + ); + }); +}); + +describe("parseBuiltMarketplaceDir()", () => { + it("is undefined for a path carrying one segment too many", () => { + expect(parseBuiltMarketplaceDir("/p", "/p/.aidd/cache/built/mkt/claude/extra", "linux")).toBe( + undefined + ); + }); +}); + +describe("parseUserBuiltMarketplaceDir()", () => { + it("tolerates a trailing separator on the path", () => { + expect( + parseUserBuiltMarketplaceDir("/cfg", "/cfg/cache/built/1.0.0/mkt/claude/", "linux") + ).toStrictEqual({ + version: "1.0.0", + marketplaceName: "mkt", + target: "claude", + }); + }); +}); + +describe("parseBuiltMarketplaceDirAtAnyRoot()", () => { + it("reads back a relative project root of a single segment", () => { + expect( + parseBuiltMarketplaceDirAtAnyRoot("p/.aidd/cache/built/mkt/claude", "linux") + ).toStrictEqual({ + projectRoot: "p", + marketplaceName: "mkt", + target: "claude", + }); + }); + + it("is undefined when only part of the marker matches", () => { + expect(parseBuiltMarketplaceDirAtAnyRoot("/p/.aidd/cache/other/mkt/claude", "linux")).toBe( + undefined + ); + }); + + it("matches the marker segment by segment, never as one string", () => { + expect( + parseBuiltMarketplaceDirAtAnyRoot("/p/.aidd/cache/built/mkt/claude", "linux") + ).toStrictEqual({ + projectRoot: "/p", + marketplaceName: "mkt", + target: "claude", + }); + }); +}); + +describe("samePathSegment()", () => { + it("tells two different names apart on win32 too", () => { + expect(samePathSegment("alpha", "beta", "win32")).toBe(false); + }); +}); + +describe("samePath()", () => { + it("calls a realpath answer and a stored path the same directory when only separators differ", () => { + expect(samePath("/user-cache/built/1.0.0", "\\user-cache\\built\\1.0.0", "win32")).toBe(true); + }); + + it("still tells two different directories apart", () => { + expect(samePath("/user-cache/built/1.0.0", "\\user-cache\\built\\2.0.0", "win32")).toBe(false); + }); + + it("folds case where the filesystem does, and nowhere else", () => { + expect(samePath("/Cache/Built", "/cache/built", "win32")).toBe(true); + expect(samePath("/Cache/Built", "/cache/built", "linux")).toBe(false); + }); + + it("leaves a posix path that legitimately holds a backslash alone on posix", () => { + expect(samePath("/odd/a", "/odd\\a", "linux")).toBe(false); + }); +}); diff --git a/cli/tests/kernel/reading/aidd-config-dir.unit.test.ts b/cli/tests/kernel/reading/aidd-config-dir.unit.test.ts new file mode 100644 index 000000000..3b6a331d9 --- /dev/null +++ b/cli/tests/kernel/reading/aidd-config-dir.unit.test.ts @@ -0,0 +1,12 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveAiddConfigDir, resolveHomeDir } from "../../../src/kernel/reading/home-dir.js"; + +describe("resolveAiddConfigDir", () => { + it.skipIf(process.platform === "win32")( + "sits under .config in the home directory on POSIX", + () => { + expect(resolveAiddConfigDir()).toBe(join(resolveHomeDir(), ".config", "aidd")); + } + ); +}); diff --git a/cli/tests/kernel/reading/confined-file-name.unit.test.ts b/cli/tests/kernel/reading/confined-file-name.unit.test.ts new file mode 100644 index 000000000..365e778b1 --- /dev/null +++ b/cli/tests/kernel/reading/confined-file-name.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { isBareFileName } from "../../../src/kernel/reading/confined-file-name.js"; + +describe("isBareFileName", () => { + it("accepts a plain file name", () => { + expect(isBareFileName("manifest.json")).toBe(true); + }); + + it("refuses an empty name", () => { + expect(isBareFileName("")).toBe(false); + }); + + it("refuses the directory itself", () => { + expect(isBareFileName(".")).toBe(false); + }); + + it("refuses the parent directory", () => { + expect(isBareFileName("..")).toBe(false); + }); + + it("refuses a name that walks out of the directory", () => { + expect(isBareFileName("../manifest.json")).toBe(false); + }); + + it("refuses a nested path", () => { + expect(isBareFileName("sub/manifest.json")).toBe(false); + }); + + it("refuses an absolute path", () => { + expect(isBareFileName("/etc/passwd")).toBe(false); + }); +}); diff --git a/cli/tests/kernel/reading/json-file.unit.test.ts b/cli/tests/kernel/reading/json-file.unit.test.ts new file mode 100644 index 000000000..010004ae4 --- /dev/null +++ b/cli/tests/kernel/reading/json-file.unit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { asPlainObjectOrEmpty, isErrnoException } from "../../../src/kernel/reading/json-file.js"; + +describe("asPlainObjectOrEmpty", () => { + it("passes a plain object through unchanged", () => { + const value = { a: 1 }; + expect(asPlainObjectOrEmpty(value)).toBe(value); + }); + + it("reads null as an empty object", () => { + expect(asPlainObjectOrEmpty(null)).toStrictEqual({}); + }); + + it("reads an array as an empty object", () => { + expect(asPlainObjectOrEmpty([1])).toStrictEqual({}); + }); + + it("reads a primitive as an empty object", () => { + expect(asPlainObjectOrEmpty("text")).toStrictEqual({}); + }); +}); + +describe("isErrnoException", () => { + it("recognises an Error carrying a code", () => { + expect(isErrnoException(Object.assign(new Error("gone"), { code: "ENOENT" }))).toBe(true); + }); + + it("refuses an Error carrying no code", () => { + expect(isErrnoException(new Error("gone"))).toBe(false); + }); + + it("refuses a plain object carrying a code", () => { + expect(isErrnoException({ code: "ENOENT" })).toBe(false); + }); +}); diff --git a/cli/tests/kernel/reading/jsonc.unit.test.ts b/cli/tests/kernel/reading/jsonc.unit.test.ts new file mode 100644 index 000000000..f8b004366 --- /dev/null +++ b/cli/tests/kernel/reading/jsonc.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { stripJsonComments } from "../../../src/kernel/reading/jsonc.js"; + +describe("stripJsonComments", () => { + describe("a line comment", () => { + it("is dropped up to the end of its line, the line ending kept", () => { + expect(stripJsonComments("a //c\nb")).toBe("a \nb"); + }); + + it("is dropped when it closes the document with no line ending", () => { + expect(stripJsonComments("1 // c")).toBe("1 "); + }); + }); + + describe("a block comment", () => { + it("is dropped whole, its inner asterisks included", () => { + expect(stripJsonComments("/* a * b */1")).toBe("1"); + }); + + it("is dropped whole when a slash follows its opening marker", () => { + expect(stripJsonComments("/*/ x */1")).toBe("1"); + }); + + it("is dropped to the end of the document when never closed", () => { + expect(stripJsonComments("1 /* x")).toBe("1 "); + }); + }); + + describe("a lone slash", () => { + it("is not a comment and stays", () => { + expect(stripJsonComments("1/2")).toBe("1/2"); + }); + }); + + describe("inside a string", () => { + it("an escaped quote does not end the string, so a comment marker after it stays", () => { + expect(stripJsonComments('{"a":"x\\"y // kept"}')).toBe('{"a":"x\\"y // kept"}'); + }); + + it("a backslash closing the document is kept as it is", () => { + expect(stripJsonComments('"x\\')).toBe('"x\\'); + }); + }); + + describe("a trailing comma", () => { + it("is dropped before a closing brace", () => { + expect(stripJsonComments('{"a":1,\n}')).toBe('{"a":1\n}'); + }); + + it("is dropped before a closing bracket", () => { + expect(stripJsonComments("[1,2,]")).toBe("[1,2]"); + }); + + it("is kept when a value follows", () => { + expect(stripJsonComments("[1, 2]")).toBe("[1, 2]"); + }); + }); +}); diff --git a/cli/tests/kernel/semver-precedence.unit.test.ts b/cli/tests/kernel/semver-precedence.unit.test.ts new file mode 100644 index 000000000..bad58cd61 --- /dev/null +++ b/cli/tests/kernel/semver-precedence.unit.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { compareSemver, isSemver } from "../../src/kernel/semver.js"; + +describe("isSemver(), component width", () => { + it("accepts a multi-digit major", () => { + expect(isSemver("10.0.0")).toBe(true); + }); + + it("accepts a multi-digit patch", () => { + expect(isSemver("1.0.10")).toBe(true); + }); +}); + +describe("compareSemver(), release components", () => { + it("orders by patch when major and minor agree", () => { + expect([compareSemver("1.0.1", "1.0.2"), compareSemver("1.0.2", "1.0.1")]).toStrictEqual([ + -1, 1, + ]); + }); + + it("reads an unparseable version as 0.0.0", () => { + expect(compareSemver("garbage", "0.0.0")).toBe(0); + }); +}); + +describe("compareSemver(), pre-release identifiers", () => { + it("is 0 for two identical pre-release versions", () => { + expect(compareSemver("1.0.0-rc.1", "1.0.0-rc.1")).toBe(0); + }); + + it("orders numeric identifiers numerically in both directions", () => { + expect(compareSemver("1.0.0-rc.10", "1.0.0-rc.2")).toBe(1); + }); + + it("orders lexical identifiers in both directions", () => { + expect(compareSemver("1.0.0-beta", "1.0.0-alpha")).toBe(1); + }); + + it("orders a numeric identifier below a non-numeric one", () => { + expect([ + compareSemver("1.0.0-1", "1.0.0-alpha"), + compareSemver("1.0.0-alpha", "1.0.0-1"), + ]).toStrictEqual([-1, 1]); + }); + + it("orders a numeric identifier below a non-numeric one that sorts first as text", () => { + expect(compareSemver("1.0.0--x", "1.0.0-1")).toBe(1); + }); + + it("reads an identifier as numeric only when it is digits throughout", () => { + expect([ + compareSemver("1.0.0-alpha1", "1.0.0-alpha2"), + compareSemver("1.0.0-1a", "1.0.0-1b"), + ]).toStrictEqual([-1, -1]); + }); + + it("compares multi-digit identifiers by value, not by their first digit", () => { + expect(compareSemver("1.0.0-rc.20", "1.0.0-rc.100")).toBe(-1); + }); + + it("orders the shorter list below on a shared prefix", () => { + expect([ + compareSemver("1.0.0-rc", "1.0.0-rc.1"), + compareSemver("1.0.0-rc.1", "1.0.0-rc"), + ]).toStrictEqual([-1, 1]); + }); +}); diff --git a/cli/tests/kernel/source-messages.unit.test.ts b/cli/tests/kernel/source-messages.unit.test.ts new file mode 100644 index 000000000..68c43b575 --- /dev/null +++ b/cli/tests/kernel/source-messages.unit.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { InvalidPluginSourceError } from "../../src/kernel/errors.js"; +import { + parsePluginSource, + parsePluginSourceShorthand, + serializePluginSource, +} from "../../src/kernel/source.js"; + +describe("what a refused source is told", () => { + it.each([ + [{ kind: "github", repo: "not-a-repo" }, '"repo" must match owner/repo format.'], + [{ kind: "url" }, '"url" must be a non-empty string.'], + [{ kind: "git-subdir", path: "p" }, '"url" must be a non-empty string.'], + [{ kind: "git-subdir", url: "u" }, '"path" must be a non-empty string.'], + [{ kind: "npm" }, '"package" must be a non-empty string.'], + [ + { kind: "npm", package: "My-Plugin" }, + '"package" must be a valid npm package name (e.g. my-plugin or @scope/my-plugin). Got: "My-Plugin"', + ], + [ + { kind: "npm", package: "my-plugin!" }, + '"package" must be a valid npm package name (e.g. my-plugin or @scope/my-plugin). Got: "my-plugin!"', + ], + [ + { kind: "github", repo: "owner/repo", sha: "a".repeat(41) }, + '"sha" must be a 40-character lowercase hex string.', + ], + [42, "expected an object."], + ["github:owner/repo", 'string source "github:owner/repo" is not a recognized path or repo.'], + ])("refuses %j saying: %s", (raw, detail) => { + expect(() => parsePluginSource(raw)).toThrow(new InvalidPluginSourceError(detail)); + }); +}); + +describe("what a parsed source carries, field by field", () => { + it("records no ref on a gitlab shorthand given none", () => { + expect(parsePluginSourceShorthand("gitlab:my-org/my-plugin")).toStrictEqual({ + kind: "url", + url: "https://gitlab.com/my-org/my-plugin.git", + }); + }); + + it("serializes a minimal url source with no absent field", () => { + expect(serializePluginSource({ kind: "url", url: "https://x/p.git" })).toStrictEqual({ + kind: "url", + url: "https://x/p.git", + }); + }); + + it("serializes a minimal git-subdir source with no absent field", () => { + expect( + serializePluginSource({ kind: "git-subdir", url: "https://x/r.git", path: "pkg" }) + ).toStrictEqual({ kind: "git-subdir", url: "https://x/r.git", path: "pkg" }); + }); + + it("serializes a minimal npm source with no absent field", () => { + expect(serializePluginSource({ kind: "npm", package: "pkg" })).toStrictEqual({ + kind: "npm", + package: "pkg", + }); + }); +}); diff --git a/cli/tests/presentation/commands/framework-wiring.integration.test.ts b/cli/tests/presentation/commands/framework-wiring.integration.test.ts index 277bb2f70..2812760bd 100644 --- a/cli/tests/presentation/commands/framework-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/framework-wiring.integration.test.ts @@ -192,7 +192,7 @@ describe("aidd framework install", () => { }); describe("aidd framework remove", () => { - it("removes one AI tool with no MCP narrowing, and counts every file that went", async () => { + it("removes one AI tool, and counts every file that went", async () => { uninstallAiTools.mockResolvedValue([ { toolId: "claude", fileCount: 4 }, { toolId: "claude", fileCount: 3 }, @@ -202,7 +202,6 @@ describe("aidd framework remove", () => { expect(uninstallAiTools).toHaveBeenCalledWith({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, - mcpFilter: [], }); expect(uninstallIdeTool).not.toHaveBeenCalled(); }); diff --git a/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts b/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts index 6491de134..63b5de066 100644 --- a/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts @@ -5,6 +5,7 @@ import { parsePluginSourceShorthand } from "../../../src/kernel/source.js"; const marketplaceAdd = vi.fn(); const marketplaceList = vi.fn(); const marketplaceRemove = vi.fn(); +const userMarketplaceRemove = vi.fn(); const marketplaceRefresh = vi.fn(); const marketplaceCheck = vi.fn(); const activation = vi.fn(); @@ -17,6 +18,7 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ marketplaceAddUseCase: { execute: marketplaceAdd }, marketplaceListUseCase: { execute: marketplaceList }, marketplaceRemoveUseCase: { execute: marketplaceRemove }, + userMarketplaceRemoveUseCase: { execute: userMarketplaceRemove }, marketplaceRefreshUseCase: { execute: marketplaceRefresh }, marketplaceCheckUseCase: { execute: marketplaceCheck }, marketplaceSyncSettingsUseCase: { execute: activation }, @@ -62,6 +64,10 @@ beforeEach(() => { marketplace: { name: "market-b" }, removedPluginCount: 2, }); + userMarketplaceRemove.mockResolvedValue({ + marketplace: { name: "market-b" }, + removedPluginCount: 1, + }); marketplaceRefresh.mockResolvedValue({ results: [{ name: "market-b", status: "refreshed" }], failedCount: 0, @@ -265,6 +271,31 @@ describe("aidd marketplace — a failed read or removal", () => { }); describe("aidd marketplace remove", () => { + it("routes explicit user removal to machine ownership without re-driving project activation", async () => { + expect(await run("remove", "market-b", "--scope", "user")).toEqual([ + "Marketplace 'market-b' removed (1 plugin(s) cleaned up).", + ]); + expect(userMarketplaceRemove).toHaveBeenCalledWith({ + name: "market-b", + projectRoot: PROJECT_ROOT, + autoConfirm: false, + scope: "user", + }); + expect(marketplaceRemove).not.toHaveBeenCalled(); + expect(activation).not.toHaveBeenCalled(); + }); + + it("refuses an invalid removal scope before wiring or mutation", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect(run("remove", "market-b", "--scope", "machine")).rejects.toThrow("exited"); + expect(errors.join("")).toBe("Error: Invalid scope 'machine'. Expected 'project' or 'user'.\n"); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + expect(marketplaceRemove).not.toHaveBeenCalled(); + expect(userMarketplaceRemove).not.toHaveBeenCalled(); + }); + it("removes the named one, re-drives every activation, and counts what went with it", async () => { expect(await run("remove", "market-b")).toEqual([ "Marketplace 'market-b' removed (2 plugin(s) cleaned up).", @@ -273,6 +304,7 @@ describe("aidd marketplace remove", () => { name: "market-b", projectRoot: PROJECT_ROOT, autoConfirm: false, + scope: "project", }); expect(activation).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT, @@ -409,7 +441,10 @@ describe("aidd marketplace — the help surface", () => { expect(optionsOf("list")).toEqual([ ["--plugins", "Also fetch and print all plugins from each marketplace catalog", undefined], ]); - expect(optionsOf("remove")).toEqual([["--yes", "Skip the orphan-cleanup prompt", undefined]]); + expect(optionsOf("remove")).toEqual([ + ["--yes", "Skip the orphan-cleanup prompt", undefined], + ["--scope ", "Remove from project or user scope", "project"], + ]); expect(optionsOf("refresh")).toEqual([ ["--force", "Clear cache before re-fetching", undefined], ]); diff --git a/cli/tests/presentation/commands/plugin-wiring.integration.test.ts b/cli/tests/presentation/commands/plugin-wiring.integration.test.ts index 467c2ea4b..23d7deb45 100644 --- a/cli/tests/presentation/commands/plugin-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/plugin-wiring.integration.test.ts @@ -7,6 +7,7 @@ const pluginList = vi.fn(); const pluginInstall = vi.fn(); const pluginSearch = vi.fn(); const pluginUpdate = vi.fn(); +const userPluginUpdate = vi.fn(); const activation = vi.fn(); const menuSelect = vi.fn(); const spawn = vi.fn(); @@ -18,6 +19,7 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ pluginInstallUseCase: { execute: pluginInstall }, pluginSearchUseCase: { execute: pluginSearch }, pluginUpdateUseCase: { execute: pluginUpdate }, + userPluginUpdateUseCase: { execute: userPluginUpdate }, marketplaceSyncSettingsUseCase: { execute: activation }, })), createMenuDeps: vi.fn(() => ({ prompter: { select: menuSelect } })), @@ -57,6 +59,7 @@ beforeEach(() => { pluginInstall.mockResolvedValue({ kind: "marketplace", installed: ["aidd-dev"] }); pluginSearch.mockResolvedValue({ hits: [] }); pluginUpdate.mockResolvedValue(["aidd-dev"]); + userPluginUpdate.mockResolvedValue(["aidd-vcs@aidd-framework"]); activation.mockResolvedValue({ binaryMissing: [], errors: [] }); menuSelect.mockResolvedValue("list"); spawn.mockResolvedValue(0); @@ -134,6 +137,7 @@ describe("aidd plugin remove", () => { pluginName: "aidd-dev", toolIds: "all", projectRoot: PROJECT_ROOT, + scope: "project", }); expect(activation).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT, @@ -267,12 +271,26 @@ describe("aidd plugin search", () => { }); describe("aidd plugin update", () => { + it.each(["copilot", "cursor"])("routes %s --scope user to machine ownership", async (toolId) => { + expect(await run("update", "aidd-vcs", "--tool", toolId, "--scope", "user")).toEqual([ + "Updated: aidd-vcs@aidd-framework.", + ]); + expect(userPluginUpdate).toHaveBeenCalledWith({ + pluginNames: ["aidd-vcs"], + toolIds: [toolId], + projectRoot: PROJECT_ROOT, + scope: "user", + }); + expect(pluginUpdate).not.toHaveBeenCalled(); + }); + it("sweeps every plugin when none was named, and lists what moved", async () => { expect(await run("update")).toEqual(["Updated: aidd-dev."]); expect(pluginUpdate).toHaveBeenCalledWith({ pluginNames: undefined, toolIds: "all", projectRoot: PROJECT_ROOT, + scope: "project", }); expect(activation).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT, @@ -360,9 +378,15 @@ describe("aidd plugin — the help surface", () => { ["--tool ", "Target AI tool (default: all installed)"], ]; - expect(optionsOf("remove")).toEqual(tool); + expect(optionsOf("remove")).toEqual([ + ...tool, + ["--scope ", "Removal scope (default: project)"], + ]); expect(optionsOf("list")).toEqual(tool); - expect(optionsOf("update")).toEqual(tool); + expect(optionsOf("update")).toEqual([ + ...tool, + ["--scope ", "Update scope (default: project)"], + ]); }); it("says what install may be told about the source, the scope and the prompts", () => { diff --git a/cli/tests/presentation/commands/translate-wiring.integration.test.ts b/cli/tests/presentation/commands/translate-wiring.integration.test.ts index e54b82ef1..5fb23b017 100644 --- a/cli/tests/presentation/commands/translate-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/translate-wiring.integration.test.ts @@ -5,6 +5,7 @@ import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import "../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { supportedBuildTargets } from "../../../src/contexts/translate/domain/build-target.js"; @@ -208,7 +209,11 @@ describe("aidd translate — the help surface", () => { option.defaultValue, ]) ).toEqual([ - ["--to ", "Conversion target (claude, cursor, copilot, codex, opencode)", undefined], + [ + "--to ", + "Conversion target (claude, cursor, copilot, codex, opencode, kilo)", + undefined, + ], ["--out ", "Output directory (marketplace dist or project root)", undefined], ["--as ", "Output layout", "marketplace"], ["--force", "Overwrite existing files at canonical paths under --out", undefined], diff --git a/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts b/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts index 172391913..fa4015ae1 100644 --- a/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts +++ b/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts @@ -522,10 +522,13 @@ describe("interactive menu — the command each pick hands over", () => { }); it.each([ - [["inspect", "doctor-tool"], "Tool (e.g. claude, cursor, copilot, codex, opencode, vscode)"], + [ + ["inspect", "doctor-tool"], + "Tool (e.g. claude, cursor, copilot, codex, opencode, kilo, vscode)", + ], [ ["manage-tools", "framework-install"], - "Tool (e.g. claude, cursor, copilot, codex, opencode, vscode)", + "Tool (e.g. claude, cursor, copilot, codex, opencode, kilo, vscode)", ], [["manage-tools", "framework-remove"], "Tool to remove"], [["manage-tools", "framework-update-one"], "Tool to update"], diff --git a/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts b/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts index 38642d781..b95714583 100644 --- a/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts +++ b/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts @@ -62,7 +62,8 @@ async function buildUseCase(prompter: Prompter = new KeepPrompter()) { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); const fetchMarketplaceSource = new FetchMarketplaceSourceUseCase(deps.pluginFetcher); const resolveMarketplace = new ResolveMarketplaceUseCase( diff --git a/cli/tests/runtime/assets/asset-loader-schemas.unit.test.ts b/cli/tests/runtime/assets/asset-loader-schemas.unit.test.ts new file mode 100644 index 000000000..ef1a1af2d --- /dev/null +++ b/cli/tests/runtime/assets/asset-loader-schemas.unit.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { BundledAssetProviderAdapter } from "../../../src/runtime/assets/asset-loader.js"; +import { REPOSITORY_ROOT } from "../../helpers/repository-root.js"; + +const SCHEMAS = join(REPOSITORY_ROOT, "cli", "assets", "schemas"); + +describe("BundledAssetProviderAdapter.loadSchema", () => { + it.each([ + ["plugin-manifest", "claude-code-plugin-manifest.json"], + ["marketplace", "copilot-plugin-marketplace.json"], + ["claude-marketplace", "claude-marketplace-manifest.json"], + ["codex-marketplace", "codex-marketplace-manifest.json"], + ["codex-plugin-manifest", "codex-plugin-manifest.json"], + ] as const)("reads %s from the bundled schema file %s", (name, file) => { + expect(new BundledAssetProviderAdapter().loadSchema(name)).toStrictEqual( + JSON.parse(readFileSync(join(SCHEMAS, file), "utf8")) + ); + }); +}); diff --git a/cli/tests/runtime/assets/asset-loader.unit.test.ts b/cli/tests/runtime/assets/asset-loader.unit.test.ts index fc1457a1f..20e58faf1 100644 --- a/cli/tests/runtime/assets/asset-loader.unit.test.ts +++ b/cli/tests/runtime/assets/asset-loader.unit.test.ts @@ -22,6 +22,16 @@ describe("BundledAssetProviderAdapter.loadConfigAsset", () => { }); }); + describe("kilo", () => { + it("returns parsed kilo.json with its schema", () => { + const asset = provider.loadConfigAsset("kilo", "kilo.json") as Record; + expect(asset).toMatchObject({ + $schema: "https://app.kilo.ai/config.json", + }); + expect(asset).not.toHaveProperty("instructions"); + }); + }); + describe("codex", () => { it("returns config.toml as raw string", () => { const asset = provider.loadConfigAsset("codex", "config.toml"); diff --git a/cli/tests/runtime/auth/auth-provider-adapter.unit.test.ts b/cli/tests/runtime/auth/auth-provider-adapter.unit.test.ts index 3b25c372d..bbad28d1b 100644 --- a/cli/tests/runtime/auth/auth-provider-adapter.unit.test.ts +++ b/cli/tests/runtime/auth/auth-provider-adapter.unit.test.ts @@ -146,7 +146,7 @@ describe("what `auth status` reports", () => { PROJECT_ROOT ); - await expect(adapter.status()).rejects.toThrow(AuthenticationError); + await expect(adapter.status()).rejects.toThrow(new AuthenticationError("invalid config")); }); }); diff --git a/cli/tests/runtime/auth/auth-reader-branches.unit.test.ts b/cli/tests/runtime/auth/auth-reader-branches.unit.test.ts new file mode 100644 index 000000000..2d9a74f45 --- /dev/null +++ b/cli/tests/runtime/auth/auth-reader-branches.unit.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { AuthConfig } from "../../../src/runtime/auth/auth.js"; +import { AuthReaderAdapter } from "../../../src/runtime/auth/auth-reader-adapter.js"; +import type { AuthStorage } from "../../../src/runtime/auth/auth-storage.js"; +import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; + +const PROJECT = "/project/.aidd/auth.json"; +const USER = "/home/user/.config/aidd/auth.json"; + +function storage(project: AuthConfig | null, user: AuthConfig | null): AuthStorage { + return { + userConfigPath: () => USER, + projectConfigPath: () => PROJECT, + read: async (path: string) => (path === PROJECT ? project : path === USER ? user : null), + write: async () => {}, + delete: async () => {}, + save: async () => {}, + readActive: async () => null, + } as AuthStorage; +} + +function stored(token: string | undefined): AuthConfig { + return { version: 1, method: "stored", level: "project", token, createdAt: "2026-01-01" }; +} + +function external(token?: string): AuthConfig { + return { version: 1, method: "external", level: "project", token, createdAt: "2026-01-01" }; +} + +function withoutEnvToken(run: () => Promise): Promise { + const saved = process.env.AIDD_TOKEN; + delete process.env.AIDD_TOKEN; + return run().finally(() => { + if (saved !== undefined) process.env.AIDD_TOKEN = saved; + }); +} + +describe("AuthReaderAdapter, which record answers", () => { + it("says where the token came from, and never the token", async () => { + const logger = new CapturingLogger(); + const reader = new AuthReaderAdapter(storage(stored("ghp_p"), null), "/project", logger); + + await withoutEnvToken(() => reader.resolve()); + + expect(logger.debugMessages).toStrictEqual(["Token resolved from project auth.json (stored)"]); + }); + + it("names the user record when that one answered", async () => { + const logger = new CapturingLogger(); + const reader = new AuthReaderAdapter(storage(null, stored("ghp_u")), "/project", logger); + + await withoutEnvToken(() => reader.resolve()); + + expect(logger.debugMessages).toStrictEqual(["Token resolved from user auth.json (stored)"]); + }); + + it("names the external provider when it answered", async () => { + const logger = new CapturingLogger(); + const reader = new AuthReaderAdapter(storage(external(), null), "/project", logger, { + resolve: () => "gh_tok", + }); + + expect(await withoutEnvToken(() => reader.resolve())).toBe("gh_tok"); + expect(logger.debugMessages).toStrictEqual([ + "Token resolved from project auth.json (external)", + ]); + }); + + it("says so when nothing answered", async () => { + const logger = new CapturingLogger(); + const reader = new AuthReaderAdapter(storage(null, null), "/project", logger); + + expect(await withoutEnvToken(() => reader.resolve())).toBeNull(); + expect(logger.debugMessages).toStrictEqual(["No token available"]); + }); + + it("names the command line when the token came from it", async () => { + const logger = new CapturingLogger(); + const reader = new AuthReaderAdapter(storage(null, null), "/project", logger, undefined, "cli"); + + expect(await reader.resolve()).toBe("cli"); + expect(logger.debugMessages).toStrictEqual(["Token given on the command line"]); + }); + + it("falls past a stored record carrying no token", async () => { + const reader = new AuthReaderAdapter(storage(stored(undefined), stored("ghp_u")), "/project"); + + expect(await withoutEnvToken(() => reader.resolve())).toBe("ghp_u"); + }); + + it("never asks the external provider for a stored record", async () => { + const reader = new AuthReaderAdapter(storage(stored(undefined), null), "/project", undefined, { + resolve: () => "gh_tok", + }); + + expect(await withoutEnvToken(() => reader.resolve())).toBeNull(); + }); + + it("asks the provider for an external record even when a stray token sits in it", async () => { + const reader = new AuthReaderAdapter(storage(external("stale"), null), "/project", undefined, { + resolve: () => "gh_tok", + }); + + expect(await withoutEnvToken(() => reader.resolve())).toBe("gh_tok"); + }); + + it("falls past an external record when the provider has nothing", async () => { + const logger = new CapturingLogger(); + const reader = new AuthReaderAdapter(storage(external(), stored("ghp_u")), "/project", logger, { + resolve: () => null, + }); + + expect(await withoutEnvToken(() => reader.resolve())).toBe("ghp_u"); + expect(logger.debugMessages).toStrictEqual(["Token resolved from user auth.json (stored)"]); + }); + + it("answers nothing for an external record when no provider was wired", async () => { + const reader = new AuthReaderAdapter(storage(external(), null), "/project"); + + expect(await withoutEnvToken(() => reader.resolve())).toBeNull(); + }); +}); diff --git a/cli/tests/runtime/auth/auth-storage-shape.integration.test.ts b/cli/tests/runtime/auth/auth-storage-shape.integration.test.ts new file mode 100644 index 000000000..80bcc0dc1 --- /dev/null +++ b/cli/tests/runtime/auth/auth-storage-shape.integration.test.ts @@ -0,0 +1,123 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuthStorageError } from "../../../src/kernel/errors.js"; +import { AuthStorage } from "../../../src/runtime/auth/auth-storage.js"; +import { makeAuthConfig } from "../../helpers/auth.js"; + +describe("AuthStorage, the shape of a record", () => { + let tempDir: string; + let path: string; + let storage: AuthStorage; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "aidd-auth-shape-")); + path = join(tempDir, "auth.json"); + storage = new AuthStorage(); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it.each([ + ["null", "null"], + ["a string", '"ghp_x"'], + ["a number", "1"], + ["an array", "[]"], + ["another version", JSON.stringify({ ...makeAuthConfig(), version: 2 })], + ["an unknown method", JSON.stringify({ ...makeAuthConfig(), method: "gh" })], + ["an unknown level", JSON.stringify({ ...makeAuthConfig(), level: "global" })], + ["no createdAt", JSON.stringify({ ...makeAuthConfig(), createdAt: undefined })], + ])("reads %s as no record at all", async (_shape, content) => { + await writeFile(path, content); + + expect(await storage.read(path)).toBeNull(); + }); + + it("reads back exactly what it wrote, pretty-printed", async () => { + const config = makeAuthConfig(); + + await storage.write(path, config); + + expect(await readFile(path, "utf-8")).toBe(JSON.stringify(config, null, 2)); + }); + + it("records an external credential with its provider and no token", async () => { + const saved = Object.create(storage) as AuthStorage; + saved.userConfigPath = () => path; + + await saved.save({ + credential: { method: "external", provider: "gh" }, + level: "user", + projectRoot: tempDir, + }); + + const written = JSON.parse(await readFile(path, "utf-8")) as Record; + expect(written).toStrictEqual({ + version: 1, + method: "external", + level: "user", + createdAt: written.createdAt, + provider: "gh", + }); + }); + + it("records a stored credential with its token and no provider", async () => { + const saved = Object.create(storage) as AuthStorage; + saved.userConfigPath = () => path; + + await saved.save({ + credential: { method: "stored", token: "ghp_x" }, + level: "user", + projectRoot: tempDir, + }); + + const written = JSON.parse(await readFile(path, "utf-8")) as Record; + expect(written).toStrictEqual({ + version: 1, + method: "stored", + level: "user", + createdAt: written.createdAt, + token: "ghp_x", + }); + }); + + it("synthesises a stored user-level record from AIDD_TOKEN", async () => { + const saved = process.env.AIDD_TOKEN; + process.env.AIDD_TOKEN = "ghp_env"; + try { + const active = await storage.readActive(tempDir); + expect(active).toStrictEqual({ + version: 1, + method: "stored", + level: "user", + token: "ghp_env", + createdAt: active?.createdAt, + }); + } finally { + if (saved === undefined) delete process.env.AIDD_TOKEN; + else process.env.AIDD_TOKEN = saved; + } + }); + + it("refuses to grant an empty Windows account", async () => { + const platform = Object.getOwnPropertyDescriptor(process, "platform"); + const username = process.env.USERNAME; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + process.env.USERNAME = ""; + try { + await expect(storage.write(path, makeAuthConfig())).rejects.toThrow( + new AuthStorageError( + "Failed to set restrictive permissions on " + + `${path}: USERNAME is not set, so no account can be granted access` + ) + ); + } finally { + if (platform !== undefined) Object.defineProperty(process, "platform", platform); + if (username === undefined) delete process.env.USERNAME; + else process.env.USERNAME = username; + } + }); +}); diff --git a/cli/tests/runtime/auth/auth-storage.integration.test.ts b/cli/tests/runtime/auth/auth-storage.integration.test.ts index 0f6b06aa3..e7a5e025a 100644 --- a/cli/tests/runtime/auth/auth-storage.integration.test.ts +++ b/cli/tests/runtime/auth/auth-storage.integration.test.ts @@ -98,7 +98,7 @@ describe("AuthStorage", () => { expect(execFileSync).toHaveBeenCalledTimes(1); const [command, args] = vi.mocked(execFileSync).mock.calls[0] ?? []; expect(command).toBe("icacls"); - expect(args).toEqual([path, "/inheritance:r", "/grant:r", expect.stringContaining(":(R,W)")]); + expect(args).toStrictEqual([path, "/inheritance:r", "/grant:r", "tester:(R,W)"]); }); it("names the account from the environment, not the %USERNAME% only a shell would expand", async () => { diff --git a/cli/tests/runtime/auth/gh-cli-verify.integration.test.ts b/cli/tests/runtime/auth/gh-cli-verify.integration.test.ts new file mode 100644 index 000000000..9dd61f328 --- /dev/null +++ b/cli/tests/runtime/auth/gh-cli-verify.integration.test.ts @@ -0,0 +1,99 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; +import { AuthenticationError } from "../../../src/kernel/errors.js"; +import { GhCliAdapter } from "../../../src/runtime/auth/gh-cli-adapter.js"; + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +const mockSpawnSync = vi.mocked(spawnSync); + +function answer(overrides: Partial>): ReturnType { + return { + pid: 1, + output: [], + stdout: "", + stderr: "", + status: 0, + signal: null, + error: undefined, + ...overrides, + } as ReturnType; +} + +describe("GhCliAdapter, the commands it runs", () => { + it("reads the token through `gh auth token`, with a bounded wait", () => { + mockSpawnSync.mockReturnValue(answer({ stdout: "ghp_abc\n" })); + + new GhCliAdapter().resolve(); + + expect(mockSpawnSync.mock.calls.at(-1)).toStrictEqual([ + "gh", + ["auth", "token"], + { timeout: 3000, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }, + ]); + }); + + it("names the login through `gh api user`, with a bounded wait", async () => { + mockSpawnSync.mockReturnValue(answer({ stdout: "octocat\n" })); + + await new GhCliAdapter().verify(); + + expect(mockSpawnSync.mock.calls.at(-1)).toStrictEqual([ + "gh", + ["api", "user", "--jq", ".login"], + { timeout: 5000, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }, + ]); + }); +}); + +describe("GhCliAdapter.verify", () => { + it("answers the login gh prints, trimmed", async () => { + mockSpawnSync.mockReturnValue(answer({ stdout: " octocat\n" })); + + expect(await new GhCliAdapter().verify()).toBe("octocat"); + }); + + it("refuses when gh is not installed", async () => { + mockSpawnSync.mockReturnValue( + answer({ error: new Error("ENOENT"), status: null, stdout: "octocat\n" }) + ); + + await expect(new GhCliAdapter().verify()).rejects.toThrow(new AuthenticationError("gh CLI")); + }); + + it("refuses when gh exits non-zero", async () => { + mockSpawnSync.mockReturnValue( + answer({ status: 1, stderr: "not logged in", stdout: "octocat\n" }) + ); + + await expect(new GhCliAdapter().verify()).rejects.toThrow(new AuthenticationError("gh CLI")); + }); + + it("refuses an empty login", async () => { + mockSpawnSync.mockReturnValue(answer({ stdout: " \n" })); + + await expect(new GhCliAdapter().verify()).rejects.toThrow(new AuthenticationError("gh CLI")); + }); +}); + +describe("GhCliAdapter.resolve, reading stderr", () => { + it("copes with gh answering no stderr stream at all", () => { + mockSpawnSync.mockReturnValue(answer({ status: 2, stderr: undefined })); + + expect(() => new GhCliAdapter().resolve()).toThrow("gh auth token exited with code 2"); + }); + + it("says the exit code is unknown when gh left none", () => { + mockSpawnSync.mockReturnValue(answer({ status: null, stderr: "" })); + + expect(() => new GhCliAdapter().resolve()).toThrow("gh auth token exited with code unknown"); + }); + + it("trims the stderr it reports", () => { + mockSpawnSync.mockReturnValue(answer({ status: 1, stderr: " boom \n" })); + + expect(() => new GhCliAdapter().resolve()).toThrow("gh auth token failed: boom"); + }); +}); diff --git a/cli/tests/runtime/auth/gh-token-adapter.unit.test.ts b/cli/tests/runtime/auth/gh-token-adapter.unit.test.ts new file mode 100644 index 000000000..145a306cf --- /dev/null +++ b/cli/tests/runtime/auth/gh-token-adapter.unit.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { AuthenticationError } from "../../../src/kernel/errors.js"; +import { GhTokenAdapter } from "../../../src/runtime/auth/gh-token-adapter.js"; +import type { + HttpGet, + HttpGetOptions, + HttpResponse, +} from "../../../src/runtime/http/http-client.js"; + +function http(body: unknown): HttpGet & { calls: { url: string; options?: HttpGetOptions }[] } { + const calls: { url: string; options?: HttpGetOptions }[] = []; + return { + calls, + get: async (url, options): Promise => { + calls.push({ url, options }); + return { body, statusCode: 200, contentType: "application/json" }; + }, + }; +} + +describe("GhTokenAdapter", () => { + it("asks GitHub who the token belongs to, with the token", async () => { + const client = http({ login: "octocat" }); + + const login = await new GhTokenAdapter(client).verifyToken("ghp_x"); + + expect({ login, calls: client.calls }).toStrictEqual({ + login: "octocat", + calls: [{ url: "https://api.github.com/user", options: { token: "ghp_x" } }], + }); + }); + + it("refuses an answer carrying no login", async () => { + await expect(new GhTokenAdapter(http({ id: 1 })).verifyToken("ghp_x")).rejects.toThrow( + new AuthenticationError("GitHub API") + ); + }); +}); diff --git a/cli/tests/runtime/filesystem/atomic-write.integration.test.ts b/cli/tests/runtime/filesystem/atomic-write.integration.test.ts new file mode 100644 index 000000000..0a053b985 --- /dev/null +++ b/cli/tests/runtime/filesystem/atomic-write.integration.test.ts @@ -0,0 +1,40 @@ +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { atomicWriteFile } from "../../../src/runtime/filesystem/atomic-write.js"; +import { HasherAdapter } from "../../../src/runtime/filesystem/hasher-adapter.js"; +import { PlatformAdapter } from "../../../src/runtime/platform/platform-adapter.js"; + +describe("atomicWriteFile", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "aidd-atomic-write-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("leaves exactly the file, holding the content as UTF-8", async () => { + const path = join(tempDir, "out.txt"); + + await atomicWriteFile(path, "héllo"); + + expect(await readdir(tempDir)).toStrictEqual(["out.txt"]); + expect(await readFile(path, "utf-8")).toBe("héllo"); + }); +}); + +describe("HasherAdapter", () => { + it("hashes the UTF-8 bytes of the content", () => { + expect(new HasherAdapter().hash("héllo").value).toBe("be50e8478cf24ff3595bc7307fb91b50"); + }); +}); + +describe("PlatformAdapter", () => { + it("answers the platform this process runs on", () => { + expect(new PlatformAdapter().current()).toBe(process.platform); + }); +}); diff --git a/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts b/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts new file mode 100644 index 000000000..1b540e71a --- /dev/null +++ b/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts @@ -0,0 +1,153 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { JsonParseError } from "../../../src/kernel/errors.js"; +import { FileAdapter } from "../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../src/runtime/filesystem/hasher-adapter.js"; +import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; + +describe("FileAdapter, at the edges", () => { + let tempDir: string; + let logger: CapturingLogger; + let fs: FileAdapter; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "aidd-fs-edges-")); + logger = new CapturingLogger(); + fs = new FileAdapter(new HasherAdapter(), logger); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe("listFilesRecursive()", () => { + it("lists every file below the directory by absolute path", async () => { + await mkdir(join(tempDir, "a", "b"), { recursive: true }); + await writeFile(join(tempDir, "a", "one.txt"), ""); + await writeFile(join(tempDir, "a", "b", "two.txt"), ""); + + expect((await fs.listFilesRecursive(join(tempDir, "a"))).sort()).toStrictEqual([ + join(tempDir, "a", "b", "two.txt"), + join(tempDir, "a", "one.txt"), + ]); + }); + + it("answers nothing for a directory that is not there", async () => { + expect(await fs.listFilesRecursive(join(tempDir, "gone"))).toStrictEqual([]); + }); + + it("skips a symlink and says which one", async () => { + await writeFile(join(tempDir, "real.txt"), ""); + await symlink(join(tempDir, "real.txt"), join(tempDir, "link.txt")); + + expect(await fs.listFilesRecursive(tempDir)).toStrictEqual([join(tempDir, "real.txt")]); + expect(logger.warnMessages).toStrictEqual([`Skipping symlink: ${join(tempDir, "link.txt")}`]); + }); + }); + + describe("listFilesRecursive(), with no logger", () => { + it("still skips the symlink, silently", async () => { + await writeFile(join(tempDir, "real.txt"), ""); + await symlink(join(tempDir, "real.txt"), join(tempDir, "link.txt")); + + expect(await new FileAdapter(new HasherAdapter()).listFilesRecursive(tempDir)).toStrictEqual([ + join(tempDir, "real.txt"), + ]); + }); + }); + + describe("listDirectory()", () => { + it("says which symlink it skipped", async () => { + await writeFile(join(tempDir, "real.txt"), ""); + await symlink(join(tempDir, "real.txt"), join(tempDir, "link.txt")); + + await fs.listDirectory(tempDir); + + expect(logger.warnMessages).toStrictEqual([`Skipping symlink: ${join(tempDir, "link.txt")}`]); + }); + }); + + describe("chmodExecutable()", () => { + it("makes the file executable", async () => { + const path = join(tempDir, "run.sh"); + await writeFile(path, "", { mode: 0o644 }); + + await fs.chmodExecutable(path); + + expect(await fs.isExecutable(path)).toBe(true); + }); + }); + + describe("deleteEmptyDirectories()", () => { + it("copes with a directory that is not there", async () => { + await expect(fs.deleteEmptyDirectories(join(tempDir, "gone"))).resolves.toBeUndefined(); + }); + + // A read-only directory blocks removal on POSIX alone, and never for root. + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "stops at a directory it cannot remove", + async () => { + const locked = join(tempDir, "locked"); + const inner = join(locked, "inner"); + await mkdir(inner, { recursive: true }); + await chmod(locked, 0o555); + try { + await fs.deleteEmptyDirectories(inner); + expect(await fs.fileExists(inner)).toBe(true); + } finally { + await chmod(locked, 0o755); + } + } + ); + }); + + describe("mergeJsonFile()", () => { + it("refuses to merge into a file it cannot parse, naming the file", async () => { + const path = join(tempDir, "settings.json"); + await writeFile(path, "{ not json"); + + await expect(fs.mergeJsonFile(path, "{}", "framework-prime")).rejects.toThrow(JsonParseError); + await expect(fs.mergeJsonFile(path, "{}", "framework-prime")).rejects.toThrow( + `Cannot parse existing JSON at ${path}: ` + ); + }); + + it("drops a prototype key from the incoming content", async () => { + const path = join(tempDir, "settings.json"); + await writeFile(path, JSON.stringify({ keep: 1 })); + + await fs.mergeJsonFile(path, '{"prototype": {"polluted": true}, "b": 2}', "framework-prime"); + + expect(JSON.parse(await readFile(path, "utf-8"))).toStrictEqual({ keep: 1, b: 2 }); + }); + + it("replaces a scalar with an incoming array outright", async () => { + const path = join(tempDir, "settings.json"); + await writeFile(path, JSON.stringify({ list: "one" })); + + await fs.mergeJsonFile(path, '{"list": ["a"]}', "framework-prime"); + + expect(JSON.parse(await readFile(path, "utf-8"))).toStrictEqual({ list: ["a"] }); + }); + + it("replaces a scalar with an incoming object outright", async () => { + const path = join(tempDir, "settings.json"); + await writeFile(path, JSON.stringify({ nested: "one" })); + + await fs.mergeJsonFile(path, '{"nested": {"a": 1}}', "framework-prime"); + + expect(JSON.parse(await readFile(path, "utf-8"))).toStrictEqual({ nested: { a: 1 } }); + }); + + it("replaces an object with an incoming null outright", async () => { + const path = join(tempDir, "settings.json"); + await writeFile(path, JSON.stringify({ nested: { a: 1 } })); + + await fs.mergeJsonFile(path, '{"nested": null}', "framework-prime"); + + expect(JSON.parse(await readFile(path, "utf-8"))).toStrictEqual({ nested: null }); + }); + }); +}); diff --git a/cli/tests/runtime/git/git-adapter.integration.test.ts b/cli/tests/runtime/git/git-adapter.integration.test.ts new file mode 100644 index 000000000..0ca84432d --- /dev/null +++ b/cli/tests/runtime/git/git-adapter.integration.test.ts @@ -0,0 +1,433 @@ +import { spawnSync } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + SESSION_TRAILER_HOOK_HEADER, + SESSION_TRAILER_TOKEN, + sessionTrailerHookLine, +} from "../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; +import { FileAdapter } from "../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../src/runtime/filesystem/hasher-adapter.js"; +import { GitAdapter } from "../../../src/runtime/git/git-adapter.js"; +import { environmentWithoutGitVariables } from "../../../src/runtime/git/git-environment.js"; + +const DELEGATE = "aidd-session-trailer.sh"; +const SCRIPT = "#!/bin/sh\necho delegate\n"; +/** A mode bit is POSIX: on Windows `access(X_OK)` answers like `F_OK`. */ +const MODE_BITS_UNOBSERVABLE = process.platform === "win32"; + +function git(cwd: string, ...args: string[]): string { + const result = spawnSync( + "git", + ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], + { cwd, encoding: "utf8", env: environmentWithoutGitVariables() } + ); + if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); + return result.stdout.trim(); +} + +async function commit(root: string, message: string): Promise { + await writeFile(join(root, `${Math.random()}.txt`), "x"); + git(root, "add", "."); + git(root, "commit", "-q", "-m", message); +} + +describe("GitAdapter", () => { + let root: string; + let outside: string; + let hooksDir: string; + let adapter: GitAdapter; + + beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), "aidd-git-adapter-"))); + outside = await realpath(await mkdtemp(join(tmpdir(), "aidd-git-adapter-outside-"))); + git(root, "init", "-q"); + hooksDir = join(root, ".git", "hooks"); + adapter = new GitAdapter(new FileAdapter(new HasherAdapter())); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + }); + + describe("isRepository", () => { + it("answers true inside a repository", async () => { + expect(await adapter.isRepository(root)).toBe(true); + }); + + it("answers false outside one", async () => { + expect(await adapter.isRepository(outside)).toBe(false); + }); + }); + + describe("listTrackedFiles", () => { + it("lists the tracked paths matching the pathspec, relative to the root", async () => { + await mkdir(join(root, "aidd_docs", "runs"), { recursive: true }); + await writeFile(join(root, "aidd_docs", "runs", "a.jsonl"), ""); + await writeFile(join(root, "other.txt"), ""); + git(root, "add", "."); + + expect(await adapter.listTrackedFiles(root, "aidd_docs/runs/")).toStrictEqual([ + "aidd_docs/runs/a.jsonl", + ]); + }); + + it("answers nothing outside a repository", async () => { + expect(await adapter.listTrackedFiles(outside, "aidd_docs/runs/")).toStrictEqual([]); + }); + }); + + describe("hasHistoryFor", () => { + it("is false while the pathspec is only staged", async () => { + await writeFile(join(root, "tracked.txt"), ""); + git(root, "add", "."); + + expect(await adapter.hasHistoryFor(root, "tracked.txt")).toBe(false); + }); + + it("is false for a pathspec staged after other commits", async () => { + await commit(root, "one"); + await writeFile(join(root, "tracked.txt"), ""); + git(root, "add", "."); + + expect(await adapter.hasHistoryFor(root, "tracked.txt")).toBe(false); + }); + + it("is true once a commit touched the pathspec", async () => { + await writeFile(join(root, "tracked.txt"), ""); + git(root, "add", "."); + git(root, "commit", "-q", "-m", "one"); + + expect(await adapter.hasHistoryFor(root, "tracked.txt")).toBe(true); + }); + + it("is false outside a repository", async () => { + expect(await adapter.hasHistoryFor(outside, "tracked.txt")).toBe(false); + }); + }); + + describe("installCommitMessageDelegate, when this CLI owns the hook", () => { + it("writes the delegate executable and a hook calling it from scratch", async () => { + const result = await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + const delegatePath = join(hooksDir, DELEGATE); + expect(result).toStrictEqual({ lineAdded: true }); + expect(await readFile(delegatePath, "utf8")).toBe(SCRIPT); + expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( + `${SESSION_TRAILER_HOOK_HEADER}\n${sessionTrailerHookLine(delegatePath)}\n` + ); + }); + + it("reports the delegate executable and the call site present afterwards", async () => { + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect( + await adapter.readCommitTrailerSetup(root, DELEGATE, SESSION_TRAILER_TOKEN, 10) + ).toStrictEqual({ + delegate: "executable", + hookExecutable: true, + callSite: "present", + hookHasOtherContent: false, + hooksDir, + }); + }); + + it("adds nothing the second time", async () => { + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT)).toStrictEqual({ + lineAdded: false, + }); + }); + + it("appends one line to an existing hook, on its own line", async () => { + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\necho mine"); + + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( + `#!/bin/sh\necho mine\n${sessionTrailerHookLine(join(hooksDir, DELEGATE))}\n` + ); + }); + + it("follows core.hooksPath rather than assuming .git/hooks", async () => { + const custom = join(root, "my-hooks"); + git(root, "config", "core.hooksPath", "my-hooks"); + + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(await readFile(join(custom, DELEGATE), "utf8")).toBe(SCRIPT); + }); + + it("installs nothing outside a repository", async () => { + expect(await adapter.installCommitMessageDelegate(outside, DELEGATE, SCRIPT)).toStrictEqual({ + lineAdded: false, + }); + }); + }); + + describe("installCommitMessageDelegate, when a manager owns the hook", () => { + it("lands the delegate in the common hooks directory and appends no line", async () => { + await writeFile(join(root, "lefthook.yml"), "pre-commit:\n"); + + const result = await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(result).toStrictEqual({ + lineAdded: false, + hookManager: "lefthook", + managerCallsDelegate: false, + }); + expect(await readFile(join(hooksDir, DELEGATE), "utf8")).toBe(SCRIPT); + await expect(readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).rejects.toThrow( + /ENOENT/ + ); + }); + + it("reads a lefthook config that names the delegate as already wired", async () => { + await writeFile(join(root, ".lefthook.yaml"), `run: sh .git/hooks/${DELEGATE}\n`); + + expect(await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT)).toStrictEqual({ + lineAdded: false, + hookManager: "lefthook", + managerCallsDelegate: true, + }); + }); + + it("reads husky's own prepare-commit-msg for the delegate", async () => { + await mkdir(join(root, ".husky"), { recursive: true }); + await writeFile(join(root, ".husky", "prepare-commit-msg"), `sh ${DELEGATE}\n`); + + expect(await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT)).toStrictEqual({ + lineAdded: false, + hookManager: "husky", + managerCallsDelegate: true, + }); + }); + + it("reads a husky directory holding no prepare-commit-msg as not wired", async () => { + await mkdir(join(root, ".husky"), { recursive: true }); + + expect(await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT)).toStrictEqual({ + lineAdded: false, + hookManager: "husky", + managerCallsDelegate: false, + }); + }); + + it("ignores core.hooksPath under a manager", async () => { + await writeFile(join(root, "lefthook.yml"), ""); + git(root, "config", "core.hooksPath", "my-hooks"); + + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(await readFile(join(hooksDir, DELEGATE), "utf8")).toBe(SCRIPT); + }); + + it("installs nothing outside a repository, and names no manager", async () => { + await writeFile(join(outside, "lefthook.yml"), ""); + + expect(await adapter.installCommitMessageDelegate(outside, DELEGATE, SCRIPT)).toStrictEqual({ + lineAdded: false, + }); + }); + }); + + describe("removeCommitMessageDelegate", () => { + it("drops the one line and deletes the delegate, leaving the rest of the hook", async () => { + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\necho mine\n"); + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + const result = await adapter.removeCommitMessageDelegate(root, DELEGATE); + + expect(result).toStrictEqual({ removed: true }); + expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( + "#!/bin/sh\necho mine\n" + ); + expect(await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).toStrictEqual({ + delegate: "absent", + hookExecutable: true, + callSite: "missing", + hookHasOtherContent: true, + hooksDir, + }); + }); + + it("reports nothing removed when nothing was installed", async () => { + expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ + removed: false, + }); + }); + + it("reports nothing removed from a hook that never called the delegate", async () => { + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\necho mine\n"); + + expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ + removed: false, + }); + expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( + "#!/bin/sh\necho mine\n" + ); + }); + + it("drops the line even when a hand edit indented it", async () => { + await mkdir(hooksDir, { recursive: true }); + const line = sessionTrailerHookLine(join(hooksDir, DELEGATE)); + await writeFile(join(hooksDir, "prepare-commit-msg"), `#!/bin/sh\n ${line}\necho mine\n`); + + expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ + removed: true, + }); + expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( + "#!/bin/sh\necho mine\n" + ); + }); + it.skipIf(MODE_BITS_UNOBSERVABLE)( + "leaves a hook that was not executable as it found it", + async () => { + await mkdir(hooksDir, { recursive: true }); + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + const hookPath = join(hooksDir, "prepare-commit-msg"); + await writeFile( + hookPath, + `#!/bin/sh\n${sessionTrailerHookLine(join(hooksDir, DELEGATE))}\n` + ); + await chmod(hookPath, 0o644); + + await adapter.removeCommitMessageDelegate(root, DELEGATE); + + expect((await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).hookExecutable).toBe( + false + ); + } + ); + + it("counts a hand-deleted delegate whose line is still there as removed", async () => { + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + await rm(join(hooksDir, DELEGATE)); + + expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ + removed: true, + }); + }); + + it("counts a delegate whose line was hand-dropped as removed", async () => { + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\n"); + + expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ + removed: true, + }); + }); + + it("looks under the manager's directory and carries the manager facts", async () => { + await writeFile(join(root, "lefthook.yml"), ""); + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ + removed: true, + hookManager: "lefthook", + managerCallsDelegate: false, + }); + }); + + it("carries the manager facts outside a repository too", async () => { + await writeFile(join(outside, "lefthook.yml"), ""); + + expect(await adapter.removeCommitMessageDelegate(outside, DELEGATE)).toStrictEqual({ + removed: false, + hookManager: "lefthook", + managerCallsDelegate: false, + }); + }); + }); + + describe("readCommitTrailerSetup", () => { + it("says no hook file and no delegate on a fresh repository", async () => { + expect(await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).toStrictEqual({ + delegate: "absent", + callSite: "no-hook-file", + hookHasOtherContent: false, + hooksDir, + }); + }); + + it("says no repository outside one", async () => { + expect(await adapter.readCommitTrailerSetup(outside, DELEGATE, "X", 1)).toStrictEqual({ + delegate: "absent", + callSite: "no-hook-file", + hookHasOtherContent: false, + hooksDirMissing: "no-repository", + }); + }); + it.skipIf(MODE_BITS_UNOBSERVABLE)("reports a hook git would refuse to run", async () => { + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\n", { mode: 0o644 }); + + expect(await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).toStrictEqual({ + delegate: "absent", + hookExecutable: false, + callSite: "missing", + hookHasOtherContent: false, + hooksDir, + }); + }); + it.skipIf(MODE_BITS_UNOBSERVABLE)( + "tells a delegate that is there but not executable from one that is missing", + async () => { + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, DELEGATE), SCRIPT, { mode: 0o644 }); + + expect((await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).delegate).toBe( + "not-executable" + ); + } + ); + + it("does not count the header the CLI writes as somebody else's content", async () => { + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\n\n \n"); + + expect( + (await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).hookHasOtherContent + ).toBe(false); + }); + + it("counts, among the last commits, those carrying the trailer", async () => { + await commit(root, "plain"); + await commit(root, `stamped\n\n${SESSION_TRAILER_TOKEN}: abc`); + await commit(root, "plain again"); + + expect( + (await adapter.readCommitTrailerSetup(root, DELEGATE, SESSION_TRAILER_TOKEN, 2)) + .recentlyCarrying + ).toStrictEqual({ carrying: 1, examined: 2 }); + }); + + it("leaves the count absent, not zero, when there is no history", async () => { + expect( + "recentlyCarrying" in (await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 5)) + ).toBe(false); + }); + + it("reports the delegate under a manager's directory even when the hooks dir is elsewhere", async () => { + await writeFile(join(root, "lefthook.yml"), `pre-commit: ${DELEGATE}`); + git(root, "config", "core.hooksPath", "my-hooks"); + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).toStrictEqual({ + delegate: "executable", + callSite: "no-hook-file", + hookHasOtherContent: false, + hooksDir: join(root, "my-hooks"), + hookManager: "lefthook", + managerCallsDelegate: true, + }); + }); + }); +}); diff --git a/cli/tests/runtime/git/git-environment.unit.test.ts b/cli/tests/runtime/git/git-environment.unit.test.ts new file mode 100644 index 000000000..6e80729d8 --- /dev/null +++ b/cli/tests/runtime/git/git-environment.unit.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { environmentWithoutGitVariables } from "../../../src/runtime/git/git-environment.js"; + +describe("environmentWithoutGitVariables", () => { + it("drops every variable git exports into a hook, and keeps the rest", () => { + expect( + environmentWithoutGitVariables({ + GIT_DIR: "/elsewhere/.git", + GIT_WORK_TREE: "/elsewhere", + GIT_INDEX_FILE: "/elsewhere/index", + PATH: "/usr/bin", + MY_GIT_THING: "kept", + }) + ).toStrictEqual({ PATH: "/usr/bin", MY_GIT_THING: "kept" }); + }); + + it("reads the process environment when given none", () => { + expect(environmentWithoutGitVariables().PATH).toBe(process.env.PATH); + }); +}); diff --git a/cli/tests/runtime/git/inject-token-hosts.unit.test.ts b/cli/tests/runtime/git/inject-token-hosts.unit.test.ts new file mode 100644 index 000000000..de802b3ca --- /dev/null +++ b/cli/tests/runtime/git/inject-token-hosts.unit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { injectTokenIntoUrl, withoutCredentials } from "../../../src/runtime/git/inject-token.js"; + +describe("injectTokenIntoUrl, Azure DevOps", () => { + it("uses an empty user with the token as password", () => { + expect(injectTokenIntoUrl("https://dev.azure.com/org/repo", "tok")).toBe( + "https://:tok@dev.azure.com/org/repo" + ); + }); +}); + +describe("withoutCredentials", () => { + it("strips a credential from an http URL as well", () => { + expect(withoutCredentials("http://user:pw@host/repo.git")).toBe("http://host/repo.git"); + }); + + it("strips only a credential at the start of the URL", () => { + expect(withoutCredentials("x https://user@host/repo")).toBe("x https://user@host/repo"); + }); + + it("leaves a URL carrying no credential alone", () => { + expect(withoutCredentials("https://host/repo.git")).toBe("https://host/repo.git"); + }); +}); diff --git a/cli/tests/runtime/http/http-client-wire.integration.test.ts b/cli/tests/runtime/http/http-client-wire.integration.test.ts new file mode 100644 index 000000000..6dcc40232 --- /dev/null +++ b/cli/tests/runtime/http/http-client-wire.integration.test.ts @@ -0,0 +1,103 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { describe, expect, it } from "vitest"; +import { HttpError, HttpRedirectError } from "../../../src/kernel/errors.js"; +import { HttpClient } from "../../../src/runtime/http/http-client.js"; + +function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) { + const server = createServer(handler); + return new Promise<{ url: string; close: () => Promise }>((resolve) => { + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as AddressInfo).port; + resolve({ + url: `http://127.0.0.1:${port}`, + close: () => new Promise((done) => server.close(() => done())), + }); + }); + }); +} + +describe("HttpClient, on the wire", () => { + it("sends GET, the path with its query, and the GitHub headers by default", async () => { + let seen: { method?: string; url?: string; agent?: string; accept?: string } = {}; + const { url, close } = await startServer((req, res) => { + seen = { + method: req.method, + url: req.url, + agent: req.headers["user-agent"], + accept: req.headers.accept, + }; + res.writeHead(200); + res.end(); + }); + try { + await new HttpClient().get(`${url}/a/b?c=1`); + expect(seen).toStrictEqual({ + method: "GET", + url: "/a/b?c=1", + agent: "aidd-cli", + accept: "application/vnd.github+json", + }); + } finally { + await close(); + } + }); + + it("answers an empty content type and the raw bytes when the server names none", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(200); + res.end("raw"); + }); + try { + const response = await new HttpClient().get(url); + expect({ ...response, body: String(response.body) }).toStrictEqual({ + body: "raw", + statusCode: 200, + contentType: "", + }); + } finally { + await close(); + } + }); + + it("follows a 301 as it follows a 302", async () => { + const target = await startServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ moved: true })); + }); + const { url, close } = await startServer((_req, res) => { + res.writeHead(301, { Location: target.url }); + res.end(); + }); + try { + expect((await new HttpClient().get(url)).body).toStrictEqual({ moved: true }); + } finally { + await close(); + await target.close(); + } + }); + + it("refuses a redirect that names no destination", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(302); + res.end(); + }); + try { + await expect(new HttpClient().get(url)).rejects.toThrow(new HttpRedirectError(url)); + } finally { + await close(); + } + }); + + it("reports HTTP 300 as unexpected, the first code past success", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(300); + res.end(); + }); + try { + await expect(new HttpClient().get(url)).rejects.toThrow(new HttpError(300, url)); + } finally { + await close(); + } + }); +}); diff --git a/cli/tests/runtime/project-root/project-root.unit.test.ts b/cli/tests/runtime/project-root/project-root.unit.test.ts new file mode 100644 index 000000000..df4e11470 --- /dev/null +++ b/cli/tests/runtime/project-root/project-root.unit.test.ts @@ -0,0 +1,40 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveProjectRoot } from "../../../src/runtime/project-root/project-root.js"; + +describe("resolveProjectRoot", () => { + const savedPwd = process.env.PWD; + let elsewhere: string; + + beforeEach(async () => { + elsewhere = await mkdtemp(join(tmpdir(), "aidd-project-root-")); + }); + + afterEach(async () => { + if (savedPwd === undefined) delete process.env.PWD; + else process.env.PWD = savedPwd; + await rm(elsewhere, { recursive: true, force: true }); + }); + + it("prefers the shell's PWD when it names another existing directory", () => { + process.env.PWD = elsewhere; + expect(resolveProjectRoot()).toBe(elsewhere); + }); + + it("answers the working directory when PWD names a directory that is gone", () => { + process.env.PWD = join(elsewhere, "gone"); + expect(resolveProjectRoot()).toBe(process.cwd()); + }); + + it("answers the working directory when PWD is unset", () => { + delete process.env.PWD; + expect(resolveProjectRoot()).toBe(process.cwd()); + }); + + it("answers the working directory when PWD already names it", () => { + process.env.PWD = process.cwd(); + expect(resolveProjectRoot()).toBe(process.cwd()); + }); +}); diff --git a/cli/tests/runtime/prompter/prompter-adapter-rendering.integration.test.ts b/cli/tests/runtime/prompter/prompter-adapter-rendering.integration.test.ts new file mode 100644 index 000000000..682bc499c --- /dev/null +++ b/cli/tests/runtime/prompter/prompter-adapter-rendering.integration.test.ts @@ -0,0 +1,152 @@ +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { + InquirerPrompterAdapter, + SilentPrompterAdapter, +} from "../../../src/runtime/prompter/prompter-adapter.js"; + +const ENTER = "\n"; +const ARROW_DOWN = "\x1b[B"; + +function makeAdapter() { + const inputStream = new PassThrough(); + const outputStream = new PassThrough(); + const rendered: string[] = []; + outputStream.on("data", (chunk: Buffer) => rendered.push(chunk.toString())); + const adapter = new InquirerPrompterAdapter({ input: inputStream, output: outputStream }); + return { adapter, inputStream, screen: () => rendered.join("") }; +} + +function press(inputStream: PassThrough, ...keys: string[]): void { + let delay = 0; + for (const key of keys) { + delay += 20; + setTimeout(() => inputStream.write(key), delay); + } +} + +describe("SilentPrompterAdapter, conflicts", () => { + const adapter = new SilentPrompterAdapter(); + + it("overwrites a single conflict", async () => { + expect(await adapter.resolveConflict("a.md", "modified")).toBe("overwrite"); + }); + + it("overwrites a bulk conflict, one at a time", async () => { + expect(await adapter.resolveConflictBulk("a.md", "deleted")).toBe("overwrite"); + }); +}); + +describe("InquirerPrompterAdapter, what the screen says", () => { + it("names the file and that it was deleted", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.resolveConflict("a.md", "deleted"); + press(inputStream, ENTER); + await result; + + expect(screen()).toContain("Conflict: a.md was deleted. What do you want to do?"); + }); + + it("names the file and that it was locally modified", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.resolveConflict("a.md", "modified"); + press(inputStream, ENTER); + await result; + + expect(screen()).toContain("Conflict: a.md was locally modified. What do you want to do?"); + }); + + it("offers overwrite and keep for a single conflict", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.resolveConflict("a.md", "modified"); + press(inputStream, ENTER); + await result; + + expect(screen()).toContain("Overwrite with latest version"); + expect(screen()).toContain("Keep my local version"); + }); + + it("offers the two bulk answers as well for a bulk conflict", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.resolveConflictBulk("a.md", "modified"); + press(inputStream, ENTER); + await result; + + expect(screen()).toContain("Overwrite with latest version"); + expect(screen()).toContain("Keep my local version"); + expect(screen()).toContain("Overwrite all remaining conflicts"); + expect(screen()).toContain("Skip all remaining conflicts"); + }); + + it("marks a choice disabled without a reason as Disabled", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.select("Pick", [ + { name: "first", value: 1, disabled: true }, + { name: "second", value: 2 }, + ]); + press(inputStream, ARROW_DOWN, ENTER); + await result; + + expect(screen()).toContain("first Disabled"); + }); + + it("shows the reason a choice is disabled", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.select("Pick", [ + { name: "first", value: 1, disabled: "not installed" }, + { name: "second", value: 2 }, + ]); + press(inputStream, ARROW_DOWN, ENTER); + await result; + + expect(screen()).toContain("first not installed"); + }); +}); + +describe("InquirerPrompterAdapter, bulk conflict answers", () => { + it("answers overwrite on Enter", async () => { + const { adapter, inputStream } = makeAdapter(); + const result = adapter.resolveConflictBulk("a.md", "modified"); + press(inputStream, ENTER); + + expect(await result).toBe("overwrite"); + }); + + it("answers keep one step down", async () => { + const { adapter, inputStream } = makeAdapter(); + const result = adapter.resolveConflictBulk("a.md", "modified"); + press(inputStream, ARROW_DOWN, ENTER); + + expect(await result).toBe("keep"); + }); + + it("answers overwrite-all two steps down", async () => { + const { adapter, inputStream } = makeAdapter(); + const result = adapter.resolveConflictBulk("a.md", "modified"); + press(inputStream, ARROW_DOWN, ARROW_DOWN, ENTER); + + expect(await result).toBe("overwrite-all"); + }); + + it("answers skip-all three steps down", async () => { + const { adapter, inputStream } = makeAdapter(); + const result = adapter.resolveConflictBulk("a.md", "modified"); + press(inputStream, ARROW_DOWN, ARROW_DOWN, ARROW_DOWN, ENTER); + + expect(await result).toBe("skip-all"); + }); +}); + +describe("InquirerPrompterAdapter, a disabled choice", () => { + it("refuses Enter, so the answer is the next enabled one", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.select("Pick", [ + { name: "first", value: 1, disabled: "not installed" }, + { name: "second", value: 2 }, + ]); + press(inputStream, ENTER, ARROW_DOWN, ENTER); + + expect(await result).toBe(2); + expect(screen()).toContain("This option is disabled and cannot be selected."); + }); +}); diff --git a/cli/tests/runtime/self-update/check-update-notice.unit.test.ts b/cli/tests/runtime/self-update/check-update-notice.unit.test.ts new file mode 100644 index 000000000..83d2247c5 --- /dev/null +++ b/cli/tests/runtime/self-update/check-update-notice.unit.test.ts @@ -0,0 +1,69 @@ +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CheckUpdateUseCase } from "../../../src/runtime/self-update/check-update-use-case.js"; +import { userConfigDir } from "../../../src/runtime/user-config-dir.js"; +import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../helpers/ports/in-memory-file-adapter.js"; + +const CACHE = join(userConfigDir(), "cache", "update-check.json"); + +function useCase(fs: InMemoryFileAdapter, current: string, logger = new CapturingLogger()) { + return new CheckUpdateUseCase( + { + fetchLatestRelease: async () => ({ version: "9.0.0", changelog: null }), + install: () => "/usr/local/bin/aidd", + }, + { get: () => current }, + logger, + fs + ); +} + +describe("CheckUpdateUseCase, the notice", () => { + it("names both versions without their v, and the command to run", async () => { + const fs = new InMemoryFileAdapter({ + [CACHE]: JSON.stringify({ checkedAt: 0, latest: "v2.0.0" }), + }); + const logger = new CapturingLogger(); + + await useCase(fs, "v1.0.0", logger).printFromCacheOnly(); + + expect(logger.warnMessages).toStrictEqual([ + "CLI update available: v1.0.0 → v2.0.0", + "Run `aidd update`.", + ]); + }); + + it("strips only a leading v, never one inside a pre-release tag", async () => { + const fs = new InMemoryFileAdapter({ + [CACHE]: JSON.stringify({ checkedAt: 0, latest: "2.0.0-preview" }), + }); + const logger = new CapturingLogger(); + + await useCase(fs, "1.0.0-dev", logger).printFromCacheOnly(); + + expect(logger.warnMessages[0]).toBe("CLI update available: v1.0.0-dev → v2.0.0-preview"); + }); + + it("says nothing when the cache cannot be read", async () => { + const fs = new InMemoryFileAdapter({ [CACHE]: "{ not json" }); + const logger = new CapturingLogger(); + + await useCase(fs, "1.0.0", logger).printFromCacheOnly(); + + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("writes the cache under the cache directory it created", async () => { + const created: string[] = []; + const fs = new InMemoryFileAdapter(); + fs.createDirectory = async (path: string) => { + created.push(path); + }; + + await useCase(fs, "1.0.0").refresh(); + + expect(created.map((path) => join(path))).toStrictEqual([dirname(CACHE)]); + expect(JSON.parse(fs.getFile(CACHE) ?? "{}").latest).toBe("9.0.0"); + }); +}); diff --git a/cli/tests/runtime/self-update/github-release-resolver-shape.unit.test.ts b/cli/tests/runtime/self-update/github-release-resolver-shape.unit.test.ts new file mode 100644 index 000000000..b0b0668a0 --- /dev/null +++ b/cli/tests/runtime/self-update/github-release-resolver-shape.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import type { HttpGet, HttpGetOptions } from "../../../src/runtime/http/http-client.js"; +import { GitHubReleaseResolverAdapter } from "../../../src/runtime/self-update/github-release-resolver-adapter.js"; + +function http(body: unknown): HttpGet & { calls: { url: string; options?: HttpGetOptions }[] } { + const calls: { url: string; options?: HttpGetOptions }[] = []; + return { + calls, + get: async (url, options) => { + calls.push({ url, options }); + return { body, statusCode: 200, contentType: "application/json" }; + }, + }; +} + +describe("GitHubReleaseResolverAdapter, the shape of an answer", () => { + it("resolves no latest when the first release names no tag", async () => { + const adapter = new GitHubReleaseResolverAdapter(http([{ tag_name: 7 }])); + + expect(await adapter.resolveLatest("o/r")).toBeNull(); + }); + + it("lists no root release when the body is not a list", async () => { + const adapter = new GitHubReleaseResolverAdapter(http({ message: "rate limited" })); + + expect(await adapter.listRootReleases("o/r")).toStrictEqual([]); + }); + + it("keeps only tags that are text", async () => { + const adapter = new GitHubReleaseResolverAdapter( + http([{ tag_name: 7 }, { tag_name: "v1.0.0" }]) + ); + + expect(await adapter.listRootReleases("o/r")).toStrictEqual(["v1.0.0"]); + }); + + it("lists releases with the token it was given", async () => { + const client = http([]); + const adapter = new GitHubReleaseResolverAdapter(client, { resolve: async () => "tok" }); + + await adapter.listRootReleases("o/r"); + + expect(client.calls).toStrictEqual([ + { url: "https://api.github.com/repos/o/r/releases?per_page=100", options: { token: "tok" } }, + ]); + }); + + it("resolves the latest with the token it was given", async () => { + const client = http([]); + const adapter = new GitHubReleaseResolverAdapter(client, { resolve: async () => "tok" }); + + await adapter.resolveLatest("o/r"); + + expect(client.calls).toStrictEqual([ + { url: "https://api.github.com/repos/o/r/releases?per_page=1", options: { token: "tok" } }, + ]); + }); +}); diff --git a/cli/tests/runtime/self-update/self-updater-adapter-edges.integration.test.ts b/cli/tests/runtime/self-update/self-updater-adapter-edges.integration.test.ts new file mode 100644 index 000000000..41c36c0e8 --- /dev/null +++ b/cli/tests/runtime/self-update/self-updater-adapter-edges.integration.test.ts @@ -0,0 +1,177 @@ +import { execSync } from "node:child_process"; +import { platform } from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FrameworkResolutionError, UpdateError } from "../../../src/kernel/errors.js"; +import type { HttpGet, HttpGetOptions } from "../../../src/runtime/http/http-client.js"; +import { SelfUpdaterAdapter } from "../../../src/runtime/self-update/self-updater-adapter.js"; +import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; + +vi.mock("node:child_process", () => ({ execSync: vi.fn() })); +vi.mock("node:os", () => ({ platform: vi.fn() })); + +const mockExecSync = vi.mocked(execSync); +const mockPlatform = vi.mocked(platform); + +const NPM_URL = "https://registry.npmjs.org/-/package/@ai-driven-dev/cli/dist-tags"; +const TAG_URL = "https://api.github.com/repos/ai-driven-dev/framework/releases/tags/cli-v1.2.3"; + +function http( + answers: Record +): HttpGet & { calls: { url: string; options?: HttpGetOptions }[] } { + const calls: { url: string; options?: HttpGetOptions }[] = []; + return { + calls, + get: async (url, options) => { + calls.push({ url, options }); + const answer = answers[url]; + if (answer instanceof Error) throw answer; + return { body: answer, statusCode: 200, contentType: "application/json" }; + }, + }; +} + +function offline(): HttpGet { + return { get: async () => ({ body: {}, statusCode: 200, contentType: "" }) }; +} + +describe("SelfUpdaterAdapter.fetchLatestRelease, the edges", () => { + it("refuses a registry answer that is not an object", async () => { + const adapter = new SelfUpdaterAdapter(http({ [NPM_URL]: null })); + + await expect(adapter.fetchLatestRelease()).rejects.toThrow( + new FrameworkResolutionError(`Unexpected npm registry response from ${NPM_URL}`) + ); + }); + + it("wraps a transport failure with the URL it was reading", async () => { + const adapter = new SelfUpdaterAdapter(http({ [NPM_URL]: new Error("socket hang up") })); + + await expect(adapter.fetchLatestRelease()).rejects.toThrow( + new FrameworkResolutionError( + `Could not resolve the latest CLI version from ${NPM_URL}: socket hang up` + ) + ); + }); + + it("reads the changelog with the token it was given", async () => { + const client = http({ [NPM_URL]: { latest: "1.2.3" }, [TAG_URL]: { body: "notes" } }); + const adapter = new SelfUpdaterAdapter(client, { + tokenProvider: { resolve: async () => "tok" }, + }); + + expect(await adapter.fetchLatestRelease()).toStrictEqual({ + version: "1.2.3", + changelog: "notes", + }); + expect(client.calls[1]).toStrictEqual({ url: TAG_URL, options: { token: "tok" } }); + }); + + it("says why the changelog is missing, on the debug channel", async () => { + const logger = new CapturingLogger(); + const client = http({ [NPM_URL]: { latest: "1.2.3" }, [TAG_URL]: new Error("offline") }); + + await new SelfUpdaterAdapter(client, { logger }).fetchLatestRelease(); + + expect(logger.debugMessages).toStrictEqual([`Changelog unavailable from ${TAG_URL}: offline`]); + }); +}); + +describe("SelfUpdaterAdapter.install, what it runs", () => { + let written: string[]; + const realWrite = process.stderr.write; + + beforeEach(() => { + vi.clearAllMocks(); + written = []; + process.stderr.write = ((chunk: string | Uint8Array) => { + written.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + }); + + afterEach(() => { + process.stderr.write = realWrite; + }); + + it("asks `which` on POSIX, reading its answer as text", () => { + mockPlatform.mockReturnValue("linux"); + mockExecSync.mockReturnValueOnce("/usr/local/bin/aidd\n").mockReturnValue(Buffer.alloc(0)); + + new SelfUpdaterAdapter(offline()).install(); + + expect(mockExecSync.mock.calls[0]).toStrictEqual(["which aidd", { encoding: "utf8" }]); + }); + + it("asks `where` on Windows", () => { + mockPlatform.mockReturnValue("win32"); + mockExecSync.mockReturnValueOnce("C:\\x\\aidd.cmd").mockReturnValue(Buffer.alloc(0)); + + new SelfUpdaterAdapter(offline()).install(); + + expect(mockExecSync.mock.calls[0]).toStrictEqual(["where aidd", { encoding: "utf8" }]); + }); + + it("runs the npm install with stderr piped, and answers the binary path", () => { + mockPlatform.mockReturnValue("linux"); + mockExecSync.mockReturnValueOnce("/usr/local/bin/aidd\n").mockReturnValue(Buffer.alloc(0)); + + const binaryPath = new SelfUpdaterAdapter(offline()).install(); + + expect(binaryPath).toBe("/usr/local/bin/aidd"); + expect(mockExecSync.mock.calls[1]).toStrictEqual([ + "npm install -g @ai-driven-dev/cli@latest", + { stdio: ["inherit", "inherit", "pipe"] }, + ]); + }); + + it("detects bun from a Windows bin directory whatever its case", () => { + mockPlatform.mockReturnValue("win32"); + mockExecSync + .mockReturnValueOnce("C:\\Users\\me\\AppData\\Local\\Bun\\Bin\\aidd.exe") + .mockReturnValue(Buffer.alloc(0)); + + new SelfUpdaterAdapter(offline()).install(); + + expect(mockExecSync.mock.calls[1]?.[0]).toBe("bun add -g @ai-driven-dev/cli@latest"); + }); + + it("echoes the failed install's stderr once, then refuses", () => { + mockPlatform.mockReturnValue("linux"); + mockExecSync.mockReturnValueOnce("/usr/local/bin/aidd").mockImplementationOnce(() => { + throw Object.assign(new Error("failed"), { stderr: Buffer.from("npm error 403\n") }); + }); + + expect(() => new SelfUpdaterAdapter(offline()).install()).toThrow(new UpdateError()); + expect(written).toStrictEqual(["npm error 403\n"]); + }); + + it("echoes nothing when the failure carried no stderr", () => { + mockPlatform.mockReturnValue("linux"); + mockExecSync.mockReturnValueOnce("/usr/local/bin/aidd").mockImplementationOnce(() => { + throw new Error("failed"); + }); + + expect(() => new SelfUpdaterAdapter(offline()).install()).toThrow(new UpdateError()); + expect(written).toStrictEqual([]); + }); + + it("echoes nothing when the failure was not even an object", () => { + mockPlatform.mockReturnValue("linux"); + mockExecSync.mockReturnValueOnce("/usr/local/bin/aidd").mockImplementationOnce(() => { + throw "failed"; + }); + + expect(() => new SelfUpdaterAdapter(offline()).install()).toThrow(new UpdateError()); + expect(written).toStrictEqual([]); + }); + + it("echoes nothing when stderr is neither text nor bytes", () => { + mockPlatform.mockReturnValue("linux"); + mockExecSync.mockReturnValueOnce("/usr/local/bin/aidd").mockImplementationOnce(() => { + throw Object.assign(new Error("failed"), { stderr: 42 }); + }); + + expect(() => new SelfUpdaterAdapter(offline()).install()).toThrow(new UpdateError()); + expect(written).toStrictEqual([]); + }); +}); diff --git a/cli/tests/runtime/wiring/create-deps.integration.test.ts b/cli/tests/runtime/wiring/create-deps.integration.test.ts new file mode 100644 index 000000000..6b4b62109 --- /dev/null +++ b/cli/tests/runtime/wiring/create-deps.integration.test.ts @@ -0,0 +1,61 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CLIOutput } from "../../../src/presentation/output.js"; +import { createDeps, createMenuDeps } from "../../../src/runtime/wiring/framework.js"; + +describe("createDeps", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-create-deps-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it("builds the graph once per project root", async () => { + const first = await createDeps(root, { verbose: false }); + + expect(await createDeps(root, { verbose: false })).toBe(first); + }); + + it("builds a separate graph for another project root", async () => { + const other = await mkdtemp(join(tmpdir(), "aidd-create-deps-other-")); + try { + expect(await createDeps(other, { verbose: false })).not.toBe( + await createDeps(root, { verbose: false }) + ); + } finally { + await rm(other, { recursive: true, force: true }); + } + }); + + it("builds a separate graph when a token is given on the command line", async () => { + expect(await createDeps(root, { verbose: false, token: "ghp_x" })).not.toBe( + await createDeps(root, { verbose: false }) + ); + }); + + it("builds a separate graph per token", async () => { + expect(await createDeps(root, { verbose: false, token: "ghp_a" })).not.toBe( + await createDeps(root, { verbose: false, token: "ghp_b" }) + ); + }); + + it("logs through the output it was handed", async () => { + const output = new CLIOutput(false); + const withOutput = await mkdtemp(join(tmpdir(), "aidd-create-deps-output-")); + try { + expect((await createDeps(withOutput, { verbose: false }, output)).logger).toBe(output); + } finally { + await rm(withOutput, { recursive: true, force: true }); + } + }); + + it("hands the menu a manifest repository rooted at the project", () => { + expect(createMenuDeps(root).manifestRepo.path).toBe(join(root, ".aidd", "manifest.json")); + }); +}); diff --git a/cli/tests/runtime/wiring/framework-build-modes.integration.test.ts b/cli/tests/runtime/wiring/framework-build-modes.integration.test.ts new file mode 100644 index 000000000..6bdd98ef3 --- /dev/null +++ b/cli/tests/runtime/wiring/framework-build-modes.integration.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { OutDirNotDirectoryError } from "../../../src/kernel/errors.js"; +import { BundledAssetProviderAdapter } from "../../../src/runtime/assets/asset-loader.js"; +import { createFrameworkBuildUseCase } from "../../../src/runtime/wiring/translate.js"; +import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../helpers/ports/in-memory-file-adapter.js"; +import { seedFromDirectory } from "../../helpers/ports/seed-from-directory.js"; +import { REPOSITORY_ROOT } from "../../helpers/repository-root.js"; + +const FIXTURE_DIR = join(REPOSITORY_ROOT, "cli", "tests", "fixtures", "framework"); + +describe("createFrameworkBuildUseCase, by mode", () => { + let outDir: string; + let fs: InMemoryFileAdapter; + + beforeEach(async () => { + outDir = await mkdtemp(join(tmpdir(), "aidd-build-modes-")); + fs = new InMemoryFileAdapter(); + await seedFromDirectory(fs, FIXTURE_DIR, { useAbsolutePaths: true }); + fs.setFile(`${outDir}/.keep`, ""); + }); + + afterEach(async () => { + await rm(outDir, { recursive: true, force: true }); + }); + + it("builds a marketplace tree, plugin directories included, in marketplace mode", async () => { + const useCase = createFrameworkBuildUseCase( + { fs, assetProvider: new BundledAssetProviderAdapter(), logger: new CapturingLogger() }, + { target: "claude", mode: "marketplace", outDir, force: true } + ); + if (useCase === undefined) throw new Error("claude:marketplace must be wired"); + + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir, target: "claude" }); + + expect(fs.has(`${outDir}/.claude-plugin/marketplace.json`)).toBe(true); + expect(fs.listUnder(`${outDir}/.github`)).toStrictEqual([]); + }); + + it("builds a flat tree, no marketplace catalog, in flat mode", async () => { + const useCase = createFrameworkBuildUseCase( + { fs, assetProvider: new BundledAssetProviderAdapter(), logger: new CapturingLogger() }, + { target: "copilot", mode: "flat", outDir, force: true } + ); + if (useCase === undefined) throw new Error("copilot:flat must be wired"); + + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir, target: "copilot" }); + + expect(fs.has(`${outDir}/.claude-plugin/marketplace.json`)).toBe(false); + expect(fs.listUnder(`${outDir}/.github/agents`).length).toBeGreaterThan(0); + }); + + it("refuses a flat build into a directory that is not there on disk", async () => { + const gone = join(outDir, "gone"); + fs.setFile(`${gone}/.keep`, ""); + const useCase = createFrameworkBuildUseCase( + { fs, assetProvider: new BundledAssetProviderAdapter(), logger: new CapturingLogger() }, + { target: "copilot", mode: "flat", outDir: gone, force: true } + ); + if (useCase === undefined) throw new Error("copilot:flat must be wired"); + + await expect( + useCase.execute({ sourceDir: FIXTURE_DIR, outDir: gone, target: "copilot" }) + ).rejects.toBeInstanceOf(OutDirNotDirectoryError); + }); +}); diff --git a/cli/tests/runtime/wiring/installed-plugins-from-manifest.unit.test.ts b/cli/tests/runtime/wiring/installed-plugins-from-manifest.unit.test.ts new file mode 100644 index 000000000..699227dc9 --- /dev/null +++ b/cli/tests/runtime/wiring/installed-plugins-from-manifest.unit.test.ts @@ -0,0 +1,48 @@ +import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { describe, expect, it } from "vitest"; +import { Manifest } from "../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../../src/contexts/framework/domain/ports/manifest-repository.js"; +import { installedPluginsFromManifest } from "../../../src/runtime/wiring/installed-plugins-from-manifest.js"; + +function repo(manifest: Manifest | null): ManifestRepository { + return { + path: "/proj/.aidd/manifest.json", + load: async () => manifest, + save: async () => {}, + delete: async () => {}, + }; +} + +describe("installedPluginsFromManifest", () => { + it("answers nothing at all when there is no manifest", async () => { + expect(await installedPluginsFromManifest(repo(null)).read()).toBeNull(); + }); + + it("lists only the tools that hold a plugin, by name and marketplace", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromJSON({ + name: "aidd-dev", + source: { kind: "local", path: "/p" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + marketplace: "aidd-framework", + }) + ); + + const byTool = await installedPluginsFromManifest(repo(manifest)).read(); + + expect([...(byTool ?? new Map()).entries()]).toStrictEqual([ + ["claude", [{ name: "aidd-dev", marketplace: "aidd-framework" }]], + ]); + }); + + it("names the manifest file it reads", () => { + expect(installedPluginsFromManifest(repo(null)).path).toBe("/proj/.aidd/manifest.json"); + }); +}); diff --git a/cli/tests/runtime/wiring/wire-tools.unit.test.ts b/cli/tests/runtime/wiring/wire-tools.unit.test.ts new file mode 100644 index 000000000..dffb67d11 --- /dev/null +++ b/cli/tests/runtime/wiring/wire-tools.unit.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { wireTools } from "../../../src/runtime/wiring/tools.js"; + +describe("wireTools", () => { + it("wires one native activator per tool whose profile declares a binary, keyed by that binary", () => { + const { nativePluginActivators } = wireTools(); + + expect([...nativePluginActivators.keys()].sort()).toStrictEqual(["claude", "codex", "copilot"]); + }); + + it("wires the host marketplace registry readers off the same profiles", () => { + const { hostMarketplaceRegistries } = wireTools(); + + expect([...hostMarketplaceRegistries.keys()]).toStrictEqual(["claude"]); + }); +}); diff --git a/cli/vitest.mutation.config.ts b/cli/vitest.mutation.config.ts index 7d914d51f..d8e43eb1f 100644 --- a/cli/vitest.mutation.config.ts +++ b/cli/vitest.mutation.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "vitest/config"; +import { UnloadableFileAsFailedTest } from "./tests/helpers/unloadable-file-as-failed-test.js"; import { textLoader } from "./tests/helpers/vitest-text-loader.js"; const TEXT_EXTENSIONS = [".md", ".toml"] as const; @@ -16,9 +17,16 @@ const TEXT_EXTENSIONS = [".md", ".toml"] as const; * * A plain `test.exclude` does not do this. The workspace file defines the projects, and * it wins over a config passed with `--config`; only another workspace replaces it. + * + * A test file that fails to load still counts. Stryker's vitest runner reads only the tests it + * collected, and a file whose import throws collects none, so a static mutant that stops a + * module from loading was reported as survived. `UnloadableFileAsFailedTest` gives such a file + * one failed test carrying its load error. Vitest types a test with a context only it can build; + * that test has none, and nothing reads one once the run has finished. */ export default defineConfig({ test: { + reporters: ["default", new UnloadableFileAsFailedTest()], projects: [ { plugins: [textLoader(TEXT_EXTENSIONS)], @@ -27,6 +35,7 @@ export default defineConfig({ include: ["tests/**/*.unit.test.ts"], globals: false, environment: "node", + globalSetup: ["./tests/helpers/throwaway-profile.ts"], }, }, { @@ -36,6 +45,7 @@ export default defineConfig({ include: ["tests/**/*.integration.test.ts"], globals: false, environment: "node", + globalSetup: ["./tests/helpers/throwaway-profile.ts"], testTimeout: 60000, }, }, diff --git a/cli/vitest.workspace.ts b/cli/vitest.workspace.ts index 0236f30ca..b40ac73a2 100644 --- a/cli/vitest.workspace.ts +++ b/cli/vitest.workspace.ts @@ -11,7 +11,10 @@ export default defineWorkspace([ include: ["tests/**/*.unit.test.ts"], globals: false, environment: "node", - globalSetup: ["./tests/helpers/sweep-stale-temp-dirs.ts"], + globalSetup: [ + "./tests/helpers/sweep-stale-temp-dirs.ts", + "./tests/helpers/throwaway-profile.ts", + ], }, }, { @@ -31,7 +34,10 @@ export default defineWorkspace([ globals: false, environment: "node", testTimeout: 60000, - globalSetup: ["./tests/helpers/sweep-stale-temp-dirs.ts"], + globalSetup: [ + "./tests/helpers/sweep-stale-temp-dirs.ts", + "./tests/helpers/throwaway-profile.ts", + ], }, }, { diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 2a6a85071..7014c848c 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -14,6 +14,7 @@ module.exports = { "aidd-refine", "aidd-orchestrator", "aidd-ui", + "aidd-qa", "context", "dev", "vcs", @@ -23,6 +24,7 @@ module.exports = { "ui", "aidd-telemetry", "telemetry", + "qa", "cli", "kanban", "framework", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1b0f28551..486617d92 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -96,9 +96,12 @@ Every capability lives in exactly one plugin, chosen by **concern**. This taxono | `aidd-orchestrator` | Orchestration | Coordination | | `aidd-ui` 🚧 | UI/UX design | Execution | | `aidd-telemetry` 🧪 | Measurement | Observation | +| `aidd-qa` 🆕 | Acceptance QA | Execution | `aidd-ui` is alpha: smoke-test only, off the curated install path. +`aidd-qa` is new, off the curated install path until it is proven outside this repository. It validates observable behavior against acceptance criteria and drives a browser to record evidence, so it sits in the Execution layer alongside `aidd-dev`. + `aidd-telemetry` is beta, off the curated install path: opt-in only — a repository must commit `.aidd/config.json` with `telemetry.enabled: true`. Each session appends observations, one JSON object per line, to its own `aidd_docs/runs/__.jsonl`, created on demand and git-ignored; that directory's presence is a location, not a permission. A line is never rewritten, only appended — `session_start`, `turn_end`, `file_written`, `step_start`, `step_end`, `task_declared` and `unrecognised_payload` (a path is repository-relative, never a task_id: task identity is a derivation, and belongs to whatever reads the log). Never a measurement; tokens and cost are joined afterwards from the provider's telemetry. **Observation** writes only *about* the other layers, never the artifact it describes, and nothing may depend on it. @@ -135,7 +138,7 @@ flowchart LR Recipe skills route to self-contained actions with inputs, outputs, process steps, and tests. An orchestrator with no domain logic may instead route through numbered reference protocols that define handoffs and delegate the work to capabilities discovered at runtime. -A skill never links outside itself. The same tree ships flat, where the skill folder is renamed `-`, or as a marketplace, so no relative path survives both. A bundled script is named plugin-relative in backticks, never linked. +A skill never links outside itself (`scripts/__tests__/a-skill-links-only-inside-itself.test.js`). The same tree ships flat, where the skill folder is renamed `-`, or as a marketplace, so no relative path survives both. A bundled script is named plugin-relative in backticks, never linked. ## 🤖 Skills and agents @@ -155,6 +158,11 @@ Address a capability only where the dispatch is declared: a router's `## Actions Recipe skills never hardcode a sibling provider. They discover cross-plugin capabilities at runtime through description matching. Agent permission lists and orchestration references are responsibility maps, so they name the current provider with its canonical `/plugin:folder` or `@plugin:agent` address. The orchestrator must verify that provider is installed before calling it. +Two paths are exempt, both in `isExemptFromOrthogonality`: + +- `plugins/aidd-orchestrator/**`, whose references are responsibility maps. +- `plugins/aidd-context/skills/00-onboard/**`, whose menus name addresses a person types. Temporary: it ends when that skill resolves its providers at runtime. + This distinction keeps recipe plugins swappable while making orchestration handoffs explicit and auditable. ## 🔎 See also diff --git a/docs/CATALOG.md b/docs/CATALOG.md index 66890a81c..5bc2852a4 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -10,6 +10,7 @@ The exhaustive list of AIDD plugins, skills, and actions. Skills are invoked thr - [aidd-orchestrator](#-aidd-orchestrator) - async orchestration (optional) - [aidd-ui](#-aidd-ui) - UI / UX (🚧 alpha, not ready) - [aidd-telemetry](#-aidd-telemetry) - measurement, hooks and skills (🧪 beta, off the curated path) +- [aidd-qa](#-aidd-qa) - acceptance QA (🆕 new, off the curated path) --- @@ -35,7 +36,7 @@ Bootstrap, project init, context-artifact generation, diagrams, learning, and ex ## 💻 aidd-dev -Code transformation: plan, implement, assert, audit, review, test, refactor, debug, for-sure, todo. Standalone Browser QA records short web evidence. +Code transformation: plan, implement, assert, audit, review, test, refactor, debug, for-sure, todo. | Skill | Role | Actions | | --------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | @@ -44,12 +45,11 @@ Code transformation: plan, implement, assert, audit, review, test, refactor, deb | `03-assert` | Assert features work - general, architecture, frontend UI | `01-assert`, `02-assert-architecture`, `03-assert-frontend` | | `04-audit` | Read-only codebase audit across quality pillars | `01-code-quality`, `02-architecture`, `03-security`, `04-dependencies`, `05-performance`, `06-tests`, `07-ui` | | `05-review` | Read-only review of a diff - code quality and feature behavior | `01-review-code`, `02-review-functional` | -| `06-test` | Write and iterate tests, validate user journeys in the browser | `01-test`, `02-test-journey` | +| `06-test` | Write and iterate developer-side tests, validate journeys in the browser during implementation - not independent acceptance QA or reviewer evidence | `01-test`, `02-test-journey` | | `07-refactor` | Improve code without changing behavior across four axes | `01-performance`, `02-security`, `03-cleanup`, `04-architecture` | | `08-debug` | Reproduce and fix bugs with a test-driven workflow | `01-reproduce`, `02-debug`, `03-reflect-issue` | | `09-for-sure` | Iterative loop that retries until a success condition is met | `01-init-tracking`, `02-auto-accept`, `03-autonomous-loop` | | `10-todo` | Split the prompt into independent todos, run one implementer agent per todo in parallel | `01-todo` | -| `11-browser-qa` | Record short reviewer videos for browser-scoped happy and edge cases | `00-prerequisites`, `01-load-scope`, `02-prepare-run`, `03-run-scenarios` | ## 📋 aidd-pm @@ -83,13 +83,13 @@ Meta-cognition: brainstorm, challenge, blind-spot scan, fact-check. Version-control workflows: repo init, commit, pull/merge request, release tag, issue. -| Skill | Role | Actions | -| ----------------- | ------------------------------------------------------------------------------- | ----------------------- | -| `00-repo-init` | Initialize a repo: git init, default branch, bootstrap commit, optional remote | `01-init`, `02-publish` | -| `01-commit` | Create an atomic conventional commit | `01-commit` | -| `02-pull-request` | Create a draft pull or merge request | `01-pull-request` | -| `03-release-tag` | Cut a semver release with annotated tag and notes | `01-release-tag` | -| `04-issue-create` | Create an issue in the configured ticketing tool | `01-issue-create` | +| Skill | Role | Actions | +| ----------------- | ------------------------------------------------------------------------------ | --------------------------------------- | +| `00-repo-init` | Initialize a repo: git init, default branch, bootstrap commit, optional remote | `01-init`, `02-publish` | +| `01-commit` | Commit atomically; safely retry scoped hook fixes | `01-collect`, `02-message`, `03-commit` | +| `02-pull-request` | Create a draft pull or merge request | `01-pull-request` | +| `03-release-tag` | Cut a semver release with annotated tag and notes | `01-release-tag` | +| `04-issue-create` | Create an issue in the configured ticketing tool | `01-issue-create` | ## 🎼 aidd-orchestrator @@ -121,3 +121,11 @@ CLI, and each skill says so before doing anything else if it is missing. | `00-init` | Turn measurement on for a project and prove it is recording | `01-check`, `02-enable`, `03-verify` | | `01-cost` | Answer what a period or one task cost, by step, model and tool | `01-locate`, `02-collect`, `03-report` | | `02-check` | Answer whether measurement is actually recording, line by line | `01-locate`, `02-diagnose` | + +## 🎬 aidd-qa + +Locks a scenario scope from acceptance criteria, runs it against a reviewed candidate, and hands back a per-scenario verdict with recorded evidence. Browser only, for now. + +| Skill | Role | Actions | +| ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `01-acceptance-qa` | Lock scenarios from acceptance criteria, run them, and report a verdict per scenario | `01-load-scope`, `02-prerequisites`, `03-prepare-run`, `04-run-scenarios` | diff --git a/docs/FAQ.md b/docs/FAQ.md index 9b03ff651..84d394d63 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -4,14 +4,14 @@ Most "how do I…" answers live in the README; this page covers what isn't docum ## 🤔 Why AIDD instead of your own skills? -You can write your own Claude Code skills — nothing stops you. AIDD exists because that setup is work every team repeats and re-debugs alone: the router/action split ([Skill](GLOSSARY.md#-skill) glossary entry), the plan → implement → review gating, the plugin packaging and versioning, the multi-tool support (Cursor, Copilot, Codex, OpenCode). AIDD ships that scaffolding pre-built and maintained, so you start from a working SDLC loop and only author the skill content specific to your project. If your workflow doesn't match the framework's shape, [`CREATE_PLUGIN.md`](CREATE_PLUGIN.md) shows how to build on the same scaffolding instead of replacing it. +You can write your own Claude Code skills — nothing stops you. AIDD exists because that setup is work every team repeats and re-debugs alone: the router/action split ([Skill](GLOSSARY.md#-skill) glossary entry), the plan → implement → review gating, the plugin packaging and versioning, the multi-tool support (Cursor, Copilot, Codex, OpenCode, Kilo Code). AIDD ships that scaffolding pre-built and maintained, so you start from a working SDLC loop and only author the skill content specific to your project. If your workflow doesn't match the framework's shape, [`CREATE_PLUGIN.md`](CREATE_PLUGIN.md) shows how to build on the same scaffolding instead of replacing it. ## 📦 Install, update, other tools - **Install / first run** → [Quick start](../README.md#-quick-start). - **Update plugins** → `/plugin marketplace update aidd-framework`, or see [Versioning & updates](MARKETPLACE.md#-versioning--updates). - **Private repo?** Yes — `/plugin marketplace add` just needs GitHub read access (via `gh auth login` or a PAT). -- **Cursor / Copilot / Codex / OpenCode?** Each other tool installs via its own native mechanism (project files, local plugins, or a plugin command) from the [release](https://github.com/ai-driven-dev/framework/releases/latest) archives. Steps per tool → [Other tools](../README.md#other-tools). +- **Cursor / Copilot / Codex / OpenCode / Kilo Code?** Each other tool installs via its own native mechanism (project files, local plugins, or a plugin command) from the [release](https://github.com/ai-driven-dev/framework/releases/latest) archives. Steps per tool → [Other tools](../README.md#other-tools). ## 💸 Cost and quotas diff --git a/docs/MAINTAINERS.md b/docs/MAINTAINERS.md index d57d64a3f..982af57d2 100644 --- a/docs/MAINTAINERS.md +++ b/docs/MAINTAINERS.md @@ -13,7 +13,7 @@ How to operate this repository day to day. This file is the **Maintainer** playb | Live backlog & roadmap | [Project board #8](https://github.com/orgs/ai-driven-dev/projects/8) | single source of truth | | Roles → access | GitHub teams `trusted-partners` / `certified-members` / `core-team` | mapped to the role ladder | | Branch protection | ruleset "main protection" + `.github/rulesets/main.json` | `main` is PR-only | -| Releases | release-please (`ci.yml`) + `release-please-config.json` | 10 packages (root + 8 plugins + `cli`), auto | +| Releases | release-please (`ci.yml`) + `release-please-config.json` | 11 packages (root + 9 plugins + `cli`), auto | | Pre-commit checks | `lefthook.yml` + `scripts/` | json/yaml/schema/frontmatter/catalogs/counts | ## 📅 Daily @@ -64,11 +64,11 @@ diff <(gh label list --repo ai-driven-dev/framework | cut -f1 | sort) \ release-please opens/updates a `chore: release main` PR on each push to `main`. 1. (Optional) Review the version bumps + changelog. Authored by the **aidd-bot** App, so its checks run normally. -2. CI **auto-merges** it with the App token (`--squash --admin`); no human step needed. `--admin` is required because a plain `gh pr merge` is refused even for the bypass App. +2. CI **auto-merges** it with the App token (`--merge --admin`); no human step needed. `--admin` is required because a plain `gh pr merge` is refused even for the bypass App, and `--merge` matches the single method `.github/rulesets/main.json`'s `pull_request` rule declares for `main`. 3. CI tags each bumped package, creates the GitHub Releases, and attaches the bundles: - `aidd-framework-marketplace-X.Y.Z.zip` (`.claude-plugin/` + `plugins/`) - `-vX.Y.Z.zip` - - `aidd-framework---X.Y.Z.zip` - per-tool distributions (9 archives: 4 marketplace claude/cursor/copilot/codex + 5 flat incl. opencode), produced by the `build-per-tool` matrix job in `ci.yml`. It builds the CLI from this run's own checkout and runs `translate --to --out --as ` — no published-version pin to bump. + - `aidd-framework---X.Y.Z.zip` - per-tool distributions (10 archives: 4 marketplace claude/cursor/copilot/codex + 6 flat incl. opencode and kilo), produced by the `build-per-tool` matrix job in `ci.yml`. It builds the CLI from this run's own checkout and runs `translate --to --out --as ` — no published-version pin to bump. Versions live in `.release-please-manifest.json`. Forcing a version / pre-release: `release-as` in `release-please-config.json` (remove it after the release ships). @@ -80,7 +80,7 @@ The weekly `next` → `main` promotion **must be a merge commit, never a squash - A rebase keeps the commits but recopies them under new hashes, so git never records that the branches were reconciled. The merge base between `main` and `next` then goes stale, and every later back-merge conflicts on the release metadata release-please rewrites each time — a conflict with no real content behind it. - A merge commit does both jobs: the commits land verbatim, and its second parent keeps a shared merge base so the back-merge stays clean. - Use the **Promote next to main** workflow (it merges); merging by hand, pick **Create a merge commit** and give it a conventional subject. -- The Release PR release-please opens is its own single commit and is fine to squash. +- `.github/rulesets/main.json`'s `pull_request` rule declares `allowed_merge_methods: ["merge"]`. A maintainer still has to push that file to the live `main protection` ruleset before GitHub itself refuses squash and rebase for everyone, the Release PR included; until then this rule is procedural, kept by this runbook and `ci.yml`, not yet enforced server-side. **Recovery** — a bad squashed promote turns `main` red on `Commitlint` and skips **Release Please**. An admin: @@ -88,6 +88,10 @@ The weekly `next` → `main` promotion **must be a merge commit, never a squash 2. Force-pushes `main` back to the commit before the bad merge. 3. Re-enables the ruleset, then re-runs **Promote**. +## 🚑 Hotfix merge + +Merge a `hotfix/*` PR with the merge dialog's description box cleared, or `gh pr merge --merge --body ""` — see [`RELEASE.md`](../RELEASE.md#-hotfix) for why an empty body matters here. + ## 📦 Dependencies (Dependabot) - **Patch + minor bumps auto-merge** once checks pass (`.github/workflows/dependabot-auto-merge.yml`, via the aidd-bot App). diff --git a/kanban/package.json b/kanban/package.json index 76b8d5456..e915354cd 100644 --- a/kanban/package.json +++ b/kanban/package.json @@ -19,12 +19,12 @@ "commander": "^15.0.0", "gray-matter": "^4.0.3", "ink": "7.1.1", - "react": "19.2.7" + "react": "19.3.0" }, "devDependencies": { "@biomejs/biome": "^2.5.4", "@types/node": "^26.1.1", - "@types/react": "19.2.17", + "@types/react": "19.3.0", "ink-testing-library": "4.0.0", "typescript": "^7.0.2", "vitest": "^3.2.6" diff --git a/kanban/pnpm-lock.yaml b/kanban/pnpm-lock.yaml index af74a64ac..2dae1ca03 100644 --- a/kanban/pnpm-lock.yaml +++ b/kanban/pnpm-lock.yaml @@ -120,10 +120,10 @@ importers: version: 4.0.3 ink: specifier: 7.1.1 - version: 7.1.1(@types/react@19.2.17)(react@19.2.7) + version: 7.1.1(@types/react@19.3.0)(react@19.3.0) react: - specifier: 19.2.7 - version: 19.2.7 + specifier: 19.3.0 + version: 19.3.0 devDependencies: '@biomejs/biome': specifier: ^2.5.4 @@ -132,11 +132,11 @@ importers: specifier: ^26.1.1 version: 26.1.2 '@types/react': - specifier: 19.2.17 - version: 19.2.17 + specifier: 19.3.0 + version: 19.3.0 ink-testing-library: specifier: 4.0.0 - version: 4.0.0(@types/react@19.2.17) + version: 4.0.0(@types/react@19.3.0) typescript: specifier: ^7.0.2 version: 7.0.2 @@ -503,8 +503,8 @@ packages: '@types/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} @@ -906,8 +906,8 @@ packages: peerDependencies: react: ^19.2.0 - react@19.2.7: - resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} restore-cursor@4.0.0: @@ -1332,7 +1332,7 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/react@19.2.17': + '@types/react@19.3.0': dependencies: csstype: 3.2.3 @@ -1572,11 +1572,11 @@ snapshots: indent-string@5.0.0: {} - ink-testing-library@4.0.0(@types/react@19.2.17): + ink-testing-library@4.0.0(@types/react@19.3.0): optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.3.0 - ink@7.1.1(@types/react@19.2.17)(react@19.2.7): + ink@7.1.1(@types/react@19.3.0)(react@19.3.0): dependencies: '@alcalzone/ansi-tokenize': 0.3.0 ansi-escapes: 7.3.0 @@ -1591,8 +1591,8 @@ snapshots: indent-string: 5.0.0 is-in-ci: 2.0.0 patch-console: 2.0.0 - react: 19.2.7 - react-reconciler: 0.33.0(react@19.2.7) + react: 19.3.0 + react-reconciler: 0.33.0(react@19.3.0) scheduler: 0.27.0 signal-exit: 3.0.7 slice-ansi: 9.0.0 @@ -1605,7 +1605,7 @@ snapshots: ws: 8.21.1 yoga-layout: 3.2.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.3.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -1661,12 +1661,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - react-reconciler@0.33.0(react@19.2.7): + react-reconciler@0.33.0(react@19.3.0): dependencies: - react: 19.2.7 + react: 19.3.0 scheduler: 0.27.0 - react@19.2.7: {} + react@19.3.0: {} restore-cursor@4.0.0: dependencies: diff --git a/lefthook.yml b/lefthook.yml index 483fb4f90..00d136edb 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -61,6 +61,14 @@ pre-commit: exit 0 fi node scripts/check-skill-argument-hints.mjs + architecture-rules: + glob: "plugins/*/{skills,agents}/**/*.md" + run: | + if ! command -v node >/dev/null 2>&1; then + echo "ℹ️ node not available; skipping architecture-rules" + exit 0 + fi + node scripts/check-architecture-rules.js {files} context-imports: # Two patterns: "**/" needs a literal slash, so it never matches a repository-root file. glob: @@ -175,10 +183,10 @@ pre-push: commands: cli-knip: glob: "cli/**" - run: cd cli && pnpm knip + run: node scripts/gate-witness.js cli-knip -- pnpm --dir cli knip cli-test: glob: "cli/**" - run: cd cli && pnpm test + run: node scripts/gate-witness.js cli-test -- pnpm --dir cli test commit-msg: commands: diff --git a/package.json b/package.json index 17eba7439..545bb5a97 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@commitlint/config-conventional": "^21.2.2", "ajv": "8.20.0", "ajv-formats": "3.0.1", - "js-yaml": "5.4.1", - "lefthook": "^2.1.12" + "js-yaml": "5.4.2", + "lefthook": "^2.1.14" } } diff --git a/plugins/aidd-context/skills/08-hook-generate/references/tool-paths.md b/plugins/aidd-context/skills/08-hook-generate/references/tool-paths.md index 3ecd702ba..48b1d19ef 100644 --- a/plugins/aidd-context/skills/08-hook-generate/references/tool-paths.md +++ b/plugins/aidd-context/skills/08-hook-generate/references/tool-paths.md @@ -27,7 +27,7 @@ Each tool names the same moment differently and supports a different subset. Cor | before compaction | `PreCompact` | `PreCompact` | `preCompact` | `PreCompact` | | subagent stop | `SubagentStop` | `SubagentStop` | `subagentStop` | `SubagentStop` | | turn stop | `Stop` | `Stop` | `stop` | `Stop` | -| session end | `SessionEnd` | - | `sessionEnd` | `SessionEnd` | +| session end | `SessionEnd` | `SessionEnd` | `sessionEnd` | `SessionEnd` | Each tool exposes more moments than these. For the full list, read the tool's docs: Claude `https://code.claude.com/docs/en/hooks`, Codex `https://developers.openai.com/codex/hooks`, Cursor `https://cursor.com/docs/hooks`, Copilot `https://docs.github.com/en/copilot/reference/hooks-configuration`. Confirm a moment exists before wiring it. Copilot also accepts the camelCase names (`sessionStart`, `preToolUse`). diff --git a/plugins/aidd-dev/.claude-plugin/plugin.json b/plugins/aidd-dev/.claude-plugin/plugin.json index 917597521..ef4e06046 100644 --- a/plugins/aidd-dev/.claude-plugin/plugin.json +++ b/plugins/aidd-dev/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "aidd-dev", "version": "2.5.0", - "description": "Code transformation: plan, implement, assert, audit, review, test, refactor, debug, for-sure, plus short standalone Browser QA evidence. Hosts engineering agents.", + "description": "Code transformation: plan, implement, assert, audit, review, test, refactor, debug, for-sure. Hosts engineering agents.", "author": { "name": "AI-Driven Dev", "url": "https://github.com/ai-driven-dev" @@ -17,8 +17,7 @@ "./skills/07-refactor", "./skills/08-debug", "./skills/09-for-sure", - "./skills/10-todo", - "./skills/11-browser-qa" + "./skills/10-todo" ], "agents": [ "./agents/executor.md", diff --git a/plugins/aidd-dev/CATALOG.md b/plugins/aidd-dev/CATALOG.md index 671f54194..c54c4de54 100644 --- a/plugins/aidd-dev/CATALOG.md +++ b/plugins/aidd-dev/CATALOG.md @@ -19,7 +19,6 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai - [`skills/08-debug`](#skills08-debug) - [`skills/09-for-sure`](#skills09-for-sure) - [`skills/10-todo`](#skills10-todo) - - [`skills/11-browser-qa`](#skills11-browser-qa) --- @@ -103,7 +102,7 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai |-------|------|---| | `actions` | [01-test.md](skills/06-test/actions/01-test.md) | - | | `actions` | [02-test-journey.md](skills/06-test/actions/02-test-journey.md) | - | -| `-` | [SKILL.md](skills/06-test/SKILL.md) | `Write and iterate tests until they pass, or validate a user journey end to end in the browser. Use when the user wants to add coverage, find what's untested, or walk a flow. Not for auditing test health or debugging a failure.` | +| `-` | [SKILL.md](skills/06-test/SKILL.md) | `Write and iterate developer-side tests until they pass, or validate a user journey end to end in the browser during implementation. Use when the user wants to add coverage, find what's untested, or walk a flow while building it. Do NOT use for independent acceptance QA or reviewer evidence, auditing test health, or debugging a failure.` | #### `skills/07-refactor` @@ -145,15 +144,3 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai | `actions` | [01-todo.md](skills/10-todo/actions/01-todo.md) | - | | `-` | [SKILL.md](skills/10-todo/SKILL.md) | `Split the user prompt into independent todos and run one executor agent per todo in parallel, then report a minimal table. Use when the user says "todo" or asks to fan out a multi-part request into parallel implementations.` | -#### `skills/11-browser-qa` - -| Group | File | Description | -|-------|------|---| -| `actions` | [00-prerequisites.md](skills/11-browser-qa/actions/00-prerequisites.md) | - | -| `actions` | [01-load-scope.md](skills/11-browser-qa/actions/01-load-scope.md) | - | -| `actions` | [02-prepare-run.md](skills/11-browser-qa/actions/02-prepare-run.md) | - | -| `actions` | [03-run-scenarios.md](skills/11-browser-qa/actions/03-run-scenarios.md) | - | -| `assets` | [qa-report-template.md](skills/11-browser-qa/assets/qa-report-template.md) | - | -| `references` | [run-scope-playwright-cli.md](skills/11-browser-qa/references/run-scope-playwright-cli.md) | - | -| `-` | [SKILL.md](skills/11-browser-qa/SKILL.md) | `Run post-review browser QA and produce short named videos for a locked happy path and sourced browser edge cases. Use when the user wants concise reviewer evidence for a web journey. Not for API, CLI, automated tests, diff review, or application fixes.` | - diff --git a/plugins/aidd-dev/README.md b/plugins/aidd-dev/README.md index a23bc830b..4e0ddb2cf 100644 --- a/plugins/aidd-dev/README.md +++ b/plugins/aidd-dev/README.md @@ -8,7 +8,7 @@ Code transformation plugin for the AI-Driven Development framework. First time? Install with `/plugin install aidd-dev@aidd-framework`, then run `aidd-dev:01-plan`. -Covers code transformation: planning, implementation, assertions, audits, code review, testing, refactoring, debugging, for-sure, and parallel todo fan-out. Standalone Browser QA records short web evidence. Also hosts AI agents. +Covers code transformation: planning, implementation, assertions, audits, code review, testing, refactoring, debugging, for-sure, and parallel todo fan-out. Also hosts AI agents. ## Skills @@ -19,12 +19,11 @@ Covers code transformation: planning, implementation, assertions, audits, code r | [2.3] | [assert](skills/03-assert/SKILL.md) | Assert features work as intended - general assertions, architecture conformance, and frontend UI validation. | | [2.4] | [audit](skills/04-audit/SKILL.md) | Perform deep codebase analysis to identify technical debt, dead code, and improvement opportunities. | | [2.5] | [review](skills/05-review/SKILL.md) | Review a diff along three axes: code quality, feature behavior against the plan, and relevancy (fit to the need, declared-rule conformance, no rot). | -| [2.6] | [test](skills/06-test/SKILL.md) | Write and iterate on tests until they pass, and validate user journeys end-to-end in the browser. | +| [2.6] | [test](skills/06-test/SKILL.md) | Write and iterate on developer-side tests until they pass, and validate user journeys end-to-end in the browser during implementation - not independent acceptance QA or reviewer evidence. | | [2.7] | [refactor](skills/07-refactor/SKILL.md) | Optimize code for performance and fix security vulnerabilities following OWASP guidelines. | | [2.8] | [debug](skills/08-debug/SKILL.md) | Reproduce and fix bugs systematically using test-driven workflow, root cause analysis, and hypothesis validation. | | [2.9] | [for-sure](skills/09-for-sure/SKILL.md) | Iterative agent loop that tracks attempts and retries until a success condition is met. | | [2.10] | [todo](skills/10-todo/SKILL.md) | Split the prompt into independent todos, run one executor agent per todo in parallel, then report a minimal table. | -| [2.11] | [browser-qa](skills/11-browser-qa/SKILL.md) | Record one short named video for a locked browser happy path and each sourced browser edge case. | ## Agents diff --git a/plugins/aidd-dev/skills/01-plan/actions/04-plan.md b/plugins/aidd-dev/skills/01-plan/actions/04-plan.md index 238b141c8..0803da0ff 100644 --- a/plugins/aidd-dev/skills/01-plan/actions/04-plan.md +++ b/plugins/aidd-dev/skills/01-plan/actions/04-plan.md @@ -13,7 +13,7 @@ A feature folder, always at `aidd_docs/tasks//_ scope["load-scope"] --> prepare["prepare-run"] --> run["run-scenarios"] -``` - -## Actions - -Read only the next action's file before running it. - -| # | Action | Does | -| --- | --------------- | ---------------------------------------------------------- | -| 00 | `prerequisites` | Verify the browser runner and media dependencies | -| 01 | `load-scope` | Lock one happy path and a bounded set of sourced edge cases | -| 02 | `prepare-run` | Resolve the shortest deterministic path to executable runs | -| 03 | `run-scenarios` | Record, normalize, verify, reset, and report every scenario | - -## Transversal rules - -- Run against a reviewed change and never patch the application. -- Never spawn agents. Batch independent reads and tool checks, but keep state-changing browser work sequential. -- Do not narrate action transitions, searches, fixtures, selectors, or successful checks. Report only a blocker, a required decision, or the final verdict and paths. diff --git a/plugins/aidd-dev/skills/11-browser-qa/actions/01-load-scope.md b/plugins/aidd-dev/skills/11-browser-qa/actions/01-load-scope.md deleted file mode 100644 index 6191ee5c6..000000000 --- a/plugins/aidd-dev/skills/11-browser-qa/actions/01-load-scope.md +++ /dev/null @@ -1,28 +0,0 @@ -# 01 - Load Scope - -Lock the smallest defensible browser QA scope before execution. - -## Input - -Plan path or implementation artifact. - -## Output - -- 1 locked browser happy path, -- a bounded set of sourced browser edge cases, -- a source label, -- a resolved evidence folder. - -## Process - -1. **Resolve.** the requested feature from its plan. -2. **Filter.** Keep only Happy path and Edge case sections where every task's Mermaid actor is `browser` (Ignore non-browser, mixed-channel, and unmarked scenario sections without displaying them). -3. **Lock.** Lock 1 browser happy path from the explicit user journey, then the filtered plan Test Scope, then browser-observable acceptance criteria in the implementation artifact. - - Ask one concise question only when those sources conflict or expose multiple browser journeys. -4. **Collect.** Include every filtered planned edge case + candidates from explicit browser validation, error, empty-state, permission, boundary, or recovery branches already visible in the implementation artifact. - - Search directly related browser tests only when the filtered plan contains no edge case. -5. **Bound.** Deduplicate candidates against planned edges. Keep at least 3 proposed edges, ranked by user impact, browser observability, determinism, and proximity to the requested journey. -6. **Decide.** Automatically include a proposed edge only when it is deterministic, browser-observable, in scope, and non-destructive. Require a decision only for an external or destructive action. -7. **Validate.** Reject a scenario without a source, trigger, browser-observable outcome, or executable teardown when it changes state. -8. **Locate.** Use the existing AIDD feature folder when the source belongs to one. Otherwise use `aidd_docs/tasks//_/`. -9. **Show.** Emit `Happy path: locked ()` and one compact `Edge case | Source | Decision` table. Do not repeat scenario steps. diff --git a/plugins/aidd-dev/skills/11-browser-qa/actions/02-prepare-run.md b/plugins/aidd-dev/skills/11-browser-qa/actions/02-prepare-run.md deleted file mode 100644 index f2e0a185e..000000000 --- a/plugins/aidd-dev/skills/11-browser-qa/actions/02-prepare-run.md +++ /dev/null @@ -1,27 +0,0 @@ -# 02 - Prepare Run - -Resolve the application state and scenario paths before retained recording begins. - -## Input - -Verified prerequisites and the earlier defined scope. - -## Output - -A successful prepared run with a reachable application, authenticated sessions, deterministic fixtures, executable scenario steps, and proven teardown. - -## Process - -1. **Preflight.** Check the application and fixed `1280×720` viewport. -2. **Reuse.** Read `aidd_docs/memory/testing.md` first when it exists. - - Resolve Browser QA entry, auth, fixtures, and reset from its `Browser QA` section, then a directly related browser test, then one targeted browser snapshot. - - Stop searching as soon as the run is executable. -3. **Authenticate.** Establish the required role before recording. - - Never include login discovery or secret lookup in evidence. -4. **Fixture.** Use deterministic data satisfying each setup. - - Never choose a live record by guesswork. -5. **Rehearse** only non-mutating steps and selectors. - - Never execute the final state-changing action merely to rehearse it. -6. **Reset.** Resolve an executable teardown for every state-changing scenario. - - If preparation changed state, execute the teardown and verify the baseline now; a future restart is not proof. -7. **Return.** Keep only the fixture, initial URL, minimal steps, expected outcome, teardown, and isolated session id per scenario. diff --git a/plugins/aidd-dev/skills/11-browser-qa/actions/03-run-scenarios.md b/plugins/aidd-dev/skills/11-browser-qa/actions/03-run-scenarios.md deleted file mode 100644 index 53b6deb75..000000000 --- a/plugins/aidd-dev/skills/11-browser-qa/actions/03-run-scenarios.md +++ /dev/null @@ -1,29 +0,0 @@ -# 03 - Run Scenarios - -Execute, save, and report one clean browser QA take per scenario. - -## Input - -The prepared run, source label, and resolved evidence folder. - -## Output - -`/qa.md` + 1 final WebM per scenario. - -## Process - -1. **Group.** Run at most two read-only scenarios concurrently in isolated sessions. - - Run every state-changing scenario sequentially. -2. **Record.** Apply setup before recording, then follow the recording contract in [run-scope-playwright-cli.md](../references/run-scope-playwright-cli.md). -3. **Verdict.** Compare actual with expected. - - Retain a product failure and mark the run failed. -4. **Recover.** Discard a setup or tooling failure, reset, and retry once. - - A second operational failure blocks the scenario. -5. **Reset.** Execute teardown after every state-changing take, verify the baseline, then close the session. -6. **Normalize.** Normalize at most two independent raw files concurrently. - - Save only `qa/happy-path.webm` and `qa/edge-case-.webm` after `ffprobe` and chronological frame inspection pass. -7. **Clean.** Delete raw takes and temporary validation frames only after every final file passes codec, dimension, duration, path, cut-point, and frame checks. - - Never retain screenshots or alternate media. -8. **Report.** Fill [qa-report-template.md](../assets/qa-report-template.md) with the source label. - - Keep one result row per scenario and add Findings only for a failure or blocker. -9. **Return.** Output the verdict and evidence paths, then ask `Open happy-path.webm in the browser for review?`; open the final file there when confirmed. diff --git a/plugins/aidd-dev/skills/11-browser-qa/assets/qa-report-template.md b/plugins/aidd-dev/skills/11-browser-qa/assets/qa-report-template.md deleted file mode 100644 index 464dede59..000000000 --- a/plugins/aidd-dev/skills/11-browser-qa/assets/qa-report-template.md +++ /dev/null @@ -1,12 +0,0 @@ -# Browser QA: {{feature}} - -- **Verdict**: {{pass | fail | skipped}} -- **Source**: {{plan path | implementation path | user request}} -- **Run**: {{yyyy_mm_dd}} - -## Scenarios - -| Scenario | Result | Verdict | Evidence | Duration | -| -------- | ------ | ------- | -------- | -------- | - -{{findings-section-when-needed}} diff --git a/plugins/aidd-orchestrator/skills/00-async-dev/assets/setup/workflow-template.yml b/plugins/aidd-orchestrator/skills/00-async-dev/assets/setup/workflow-template.yml index 01ad3e456..54dde4cfd 100644 --- a/plugins/aidd-orchestrator/skills/00-async-dev/assets/setup/workflow-template.yml +++ b/plugins/aidd-orchestrator/skills/00-async-dev/assets/setup/workflow-template.yml @@ -35,7 +35,7 @@ jobs: account_secret: ${{ steps.route_account.outputs.account_secret }} account_owner: ${{ steps.route_account.outputs.account_owner }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: sparse-checkout: | .claude/aidd-orchestrator.json @@ -149,7 +149,7 @@ jobs: if: needs.dispatch.outputs.mode == 'run' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ needs.dispatch.outputs.ref }} fetch-depth: 0 @@ -244,7 +244,7 @@ jobs: if: needs.dispatch.outputs.mode == 'review' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ needs.dispatch.outputs.ref }} fetch-depth: 0 diff --git a/plugins/aidd-qa/.claude-plugin/plugin.json b/plugins/aidd-qa/.claude-plugin/plugin.json new file mode 100644 index 000000000..188824f79 --- /dev/null +++ b/plugins/aidd-qa/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "aidd-qa", + "version": "0.1.0", + "description": "Acceptance QA: validates observable behavior against acceptance criteria and records reviewer evidence. Use when a reviewed candidate needs evidence that it meets its acceptance criteria. Do NOT use for diff review, unit or integration tests, or fixing the application.", + "author": { + "name": "AI-Driven Dev", + "url": "https://github.com/ai-driven-dev" + }, + "skills": [ + "./skills/01-acceptance-qa" + ], + "keywords": [ + "qa", + "acceptance", + "browser", + "evidence" + ], + "repository": "https://github.com/ai-driven-dev/framework", + "homepage": "https://ai-driven.dev", + "license": "MIT" +} diff --git a/plugins/aidd-qa/CATALOG.md b/plugins/aidd-qa/CATALOG.md new file mode 100644 index 000000000..189426589 --- /dev/null +++ b/plugins/aidd-qa/CATALOG.md @@ -0,0 +1,34 @@ +# aidd-qa catalog + +Auto-generated index of skills, agents, references and assets shipped by the `aidd-qa` plugin. + +> This file is automatically updated by the `scripts/summarize-markdown.js` script. + +## Table of Contents + +- [`.claude-plugin`](#claude-plugin) +- [`skills`](#skills) + - [`skills/01-acceptance-qa`](#skills01-acceptance-qa) + +--- + +### `.claude-plugin` + +| File | +|------| +| [plugin.json](.claude-plugin/plugin.json) | + +### `skills` + +#### `skills/01-acceptance-qa` + +| Group | File | Description | +|-------|------|---| +| `actions` | [01-load-scope.md](skills/01-acceptance-qa/actions/01-load-scope.md) | - | +| `actions` | [02-prerequisites.md](skills/01-acceptance-qa/actions/02-prerequisites.md) | - | +| `actions` | [03-prepare-run.md](skills/01-acceptance-qa/actions/03-prepare-run.md) | - | +| `actions` | [04-run-scenarios.md](skills/01-acceptance-qa/actions/04-run-scenarios.md) | - | +| `assets` | [qa-report-template.md](skills/01-acceptance-qa/assets/qa-report-template.md) | - | +| `references` | [interface-browser-playwright-cli.md](skills/01-acceptance-qa/references/interface-browser-playwright-cli.md) | - | +| `-` | [SKILL.md](skills/01-acceptance-qa/SKILL.md) | `Validate a reviewed candidate's observable behavior against its acceptance criteria and record short named videos as reviewer evidence. Use when acceptance criteria exist and a browser-observable journey needs proof it holds. Do NOT use for diff review, unit or integration tests, or application fixes.` | + diff --git a/plugins/aidd-qa/README.md b/plugins/aidd-qa/README.md new file mode 100644 index 000000000..575ce2064 --- /dev/null +++ b/plugins/aidd-qa/README.md @@ -0,0 +1,17 @@ +← [aidd-framework](../../README.md) + +# aidd-qa + +Acceptance QA concern for the AI-Driven Development framework. + +> Status: new, off the curated install path until proven. + +First time? Install with `/plugin install aidd-qa@aidd-framework`, then run `aidd-qa:01-acceptance-qa`. + +Validates a reviewed candidate's observable behavior against its acceptance criteria and records reviewer evidence. Scenarios come only from acceptance criteria, never from the diff or the source code. Browser is the only supported interface today; a scenario without a browser-observable outcome is listed out of interface rather than tested. + +## Skills + +| Skill | Description | +|---|---| +| [acceptance-qa](skills/01-acceptance-qa/SKILL.md) | Validate a reviewed candidate against its acceptance criteria and record one short named video per locked scenario. | diff --git a/plugins/aidd-qa/skills/01-acceptance-qa/SKILL.md b/plugins/aidd-qa/skills/01-acceptance-qa/SKILL.md new file mode 100644 index 000000000..6d4f83e31 --- /dev/null +++ b/plugins/aidd-qa/skills/01-acceptance-qa/SKILL.md @@ -0,0 +1,32 @@ +--- +name: 01-acceptance-qa +description: Validate a reviewed candidate's observable behavior against its acceptance criteria and record short named videos as reviewer evidence. Use when acceptance criteria exist and a browser-observable journey needs proof it holds. Do NOT use for diff review, unit or integration tests, or application fixes. +argument-hint: acceptance criteria | reviewed candidate +--- + +# Acceptance QA + +```mermaid +flowchart LR + scope["load-scope"] --> prerequisites["prerequisites"] --> prepare["prepare-run"] --> run["run-scenarios"] --> report["qa.md"] + scope -- skipped --> report +``` + +## Actions + +Read only the next action's file before running it. + +| # | Action | Does | +| --- | --------------- | ---------------------------------------------------------- | +| 01 | `load-scope` | Lock at most one happy path and the edge cases its acceptance criteria name | +| 02 | `prerequisites` | Verify the browser runner and media dependencies | +| 03 | `prepare-run` | Resolve the shortest deterministic path to executable runs | +| 04 | `run-scenarios` | Record, normalize, verify, reset, and report every scenario | + +## Transversal rules + +- Run against a reviewed change and never patch the application. +- Never delete data not proven test-only. +- Never derive a scenario from the diff or the source code; every scenario traces to an acceptance criterion. +- Never spawn agents. Batch independent reads and tool checks, but keep state-changing browser work sequential. +- Do not narrate action transitions, searches, fixtures, selectors, or successful checks. Report only a blocker, a required decision, or the final verdict and paths. diff --git a/plugins/aidd-qa/skills/01-acceptance-qa/actions/01-load-scope.md b/plugins/aidd-qa/skills/01-acceptance-qa/actions/01-load-scope.md new file mode 100644 index 000000000..94f0fad70 --- /dev/null +++ b/plugins/aidd-qa/skills/01-acceptance-qa/actions/01-load-scope.md @@ -0,0 +1,31 @@ +# 01 - Load Scope + +Lock the smallest defensible acceptance QA scope before execution. + +## Input + +Acceptance criteria (issue, spec, user story, or the user) and the reviewed candidate reference (branch, commit, or running URL). + +## Output + +- Scope: at most 1 happy path and its edge cases, each with the criteria it proves. +- Out of interface: criteria, or quoted parts, with no browser-observable outcome. +- Rejected: scenario, criterion, reason. +- Evidence folder, source label (criteria path), and candidate reference. + +## Process + +1. **Resolve.** Take criteria from the issue, spec, story, or user; never a plan. Note the candidate reference. +2. **Filter.** Keep browser-observable criteria; split a partial one and quote the rest out of interface. Never test by reading code. +3. **Locate.** Use the source's feature folder, else `aidd_docs/tasks//_/`. +4. **Skip.** Nothing kept: write `qa.md` from [qa-report-template.md](../assets/qa-report-template.md) with verdict `skipped`, then stop. +5. **Lock.** 1 happy path from the primary journey; edge cases only when a criterion names them. An edge from the diff, code, tests, or an unmapped plan edge is never a candidate. Ask once when journeys conflict or an edge is external or destructive. +6. **Validate.** Reject a scenario missing a trigger or an observable outcome; keep the reason. +7. **Show.** Evidence folder, happy path, `Edge case | Criteria | Decision` table, Out of interface and Rejected when non-empty. Never repeat steps. + +## Test + +- Every scenario traces to a criterion; none comes from a plan, the diff, code, or tests. +- An unobservable criterion or part is quoted out of interface, never scoped. +- Nothing kept => `qa.md` with `skipped`, and no later action runs. +- A rejection keeps its reason; an unmapped plan edge is never listed as rejected. diff --git a/plugins/aidd-dev/skills/11-browser-qa/actions/00-prerequisites.md b/plugins/aidd-qa/skills/01-acceptance-qa/actions/02-prerequisites.md similarity index 91% rename from plugins/aidd-dev/skills/11-browser-qa/actions/00-prerequisites.md rename to plugins/aidd-qa/skills/01-acceptance-qa/actions/02-prerequisites.md index 9090966a1..dee1bf07f 100644 --- a/plugins/aidd-dev/skills/11-browser-qa/actions/00-prerequisites.md +++ b/plugins/aidd-qa/skills/01-acceptance-qa/actions/02-prerequisites.md @@ -1,10 +1,10 @@ -# 00 - Prerequisites +# 02 - Prerequisites -Verify the runner dependencies before resolving the QA scope. +Verify the runner dependencies once the scope is locked. ## Input -None. +The locked scope. ## Output diff --git a/plugins/aidd-qa/skills/01-acceptance-qa/actions/03-prepare-run.md b/plugins/aidd-qa/skills/01-acceptance-qa/actions/03-prepare-run.md new file mode 100644 index 000000000..f2f9f495d --- /dev/null +++ b/plugins/aidd-qa/skills/01-acceptance-qa/actions/03-prepare-run.md @@ -0,0 +1,28 @@ +# 03 - Prepare Run + +Make every scenario executable before recording. + +## Input + +Verified prerequisites and the locked scope. + +## Output + +Per scenario: fixture, initial URL, minimal steps, expected outcome, proven teardown, and isolated session id. Plus every scenario rejected here, with its reason. + +## Process + +1. **Reuse.** Resolve entry, auth, fixtures, and reset from the `Browser QA` section of `aidd_docs/memory/testing.md` when it exists, then a related browser test, then one targeted snapshot. Stop once executable. + - Before starting the entry, prove the storage it mounts is test-only when its reset deletes data. Unproven: ask once, never guess. +2. **Preflight.** Check the application and fixed `1280×720` viewport. +3. **Authenticate.** Establish the role before recording; never show login discovery or secrets in evidence. +4. **Fixture.** Use deterministic data per setup; never pick a live record by guesswork. +5. **Rehearse.** Only non-mutating steps and selectors; never the final state-changing action. +6. **Reset.** Resolve a verified, executable teardown per state-changing scenario; without one, reject the scenario and carry it forward with its reason. If preparation changed state, run the teardown and verify the baseline now, not at a restart. + +## Test + +- An entry on storage not proven test-only is never started when its reset deletes data; it produces one question. +- A state-changing scenario without a verified teardown is rejected with its reason. +- No login discovery, secret, or guessed live record appears in evidence. +- Preparation that changed state is torn down and verified before the run is ready. diff --git a/plugins/aidd-qa/skills/01-acceptance-qa/actions/04-run-scenarios.md b/plugins/aidd-qa/skills/01-acceptance-qa/actions/04-run-scenarios.md new file mode 100644 index 000000000..99867f18d --- /dev/null +++ b/plugins/aidd-qa/skills/01-acceptance-qa/actions/04-run-scenarios.md @@ -0,0 +1,31 @@ +# 04 - Run Scenarios + +Record, verify, and report one clean take per scenario. + +## Input + +The prepared run and its rejections; from load-scope, the source label, candidate, evidence folder, Out of interface, and Rejected. + +## Output + +`/qa.md` and one final WebM per scenario that ran. + +## Process + +1. **Group.** Steps 1-7 cover executable scenarios only. At most two read-only ones concurrently, in isolated sessions; state-changing ones sequentially. +2. **Record.** Apply setup, then follow [interface-browser-playwright-cli.md](../references/interface-browser-playwright-cli.md). +3. **Verdict.** From each step's `expected`, `actual`, `ok`: any `ok: false` => `fail`, else `pass`. Keep the take of every `fail`. +4. **Recover.** A setup failure, throw, or non-zero exit is operational: discard the take, reset, retry once; a second one => `blocked`. +5. **Reset.** After each state-changing take, run its verified teardown, verify the baseline, then close the session. +6. **Normalize.** At most two independent raw files concurrently. Save only `qa/happy-path.webm` and `qa/edge-case-.webm` once `ffprobe` and frame inspection pass; record each final file's duration from `ffprobe`. +7. **Clean.** Delete raw takes and validation frames only after every final file passes codec, dimension, duration, path, cut-point, and frame checks. Never retain screenshots or alternate media. +8. **Report.** Fill [qa-report-template.md](../assets/qa-report-template.md), even when every scenario was rejected: one row per scenario (evidence `none` when `blocked`), Out of interface and Rejected (`none` when empty), Findings only for `fail` or `blocked`. Run verdict, in order: any `fail` => `fail`; any `blocked` or rejected => `blocked`; else `pass`. `skipped` belongs to load-scope only. +9. **Return.** Output the verdict and evidence paths; when `qa/happy-path.webm` exists, ask `Open happy-path.webm in the browser for review?` and open it when confirmed. + +## Test + +- Each row names its criteria, expected, actual, verdict (`pass`, `fail`, or `blocked`), duration, and evidence. +- Run verdict: `fail` over `blocked` or rejected over `pass`; a `pass` is never reported without the Out of interface list. +- A product mismatch yields `fail` from the returned results, never a thrown take; a second operational failure yields `blocked`. +- Raw takes and frames survive until every final file passes; then only `qa/happy-path.webm` and `qa/edge-case-.webm` remain beside `qa.md`. +- Every earlier rejection appears in the report with its reason, even when it empties the executable set, and the report still runs. diff --git a/plugins/aidd-qa/skills/01-acceptance-qa/assets/qa-report-template.md b/plugins/aidd-qa/skills/01-acceptance-qa/assets/qa-report-template.md new file mode 100644 index 000000000..a36355ceb --- /dev/null +++ b/plugins/aidd-qa/skills/01-acceptance-qa/assets/qa-report-template.md @@ -0,0 +1,21 @@ +# Acceptance QA: {{feature}} + +- **Verdict**: {{pass | fail | blocked | skipped}} +- **Source**: {{acceptance criteria path}} +- **Candidate**: {{branch | commit | running URL}} +- **Run**: {{yyyy_mm_dd}} + +## Out of interface + +{{criteria, or quoted parts, with no browser-observable outcome, or "none"}} + +## Scenarios + +| Scenario | Criteria | Expected | Actual | Verdict | Duration | Evidence | +| -------- | --------- | -------- | ------ | ------- | -------- | -------- | + +## Rejected + +{{scenarios rejected in load-scope or prepare-run, criterion — reason, or "none"}} + +{{findings-section-when-needed}} diff --git a/plugins/aidd-dev/skills/11-browser-qa/references/run-scope-playwright-cli.md b/plugins/aidd-qa/skills/01-acceptance-qa/references/interface-browser-playwright-cli.md similarity index 79% rename from plugins/aidd-dev/skills/11-browser-qa/references/run-scope-playwright-cli.md rename to plugins/aidd-qa/skills/01-acceptance-qa/references/interface-browser-playwright-cli.md index 1eb2f5ba3..0f4535309 100644 --- a/plugins/aidd-dev/skills/11-browser-qa/references/run-scope-playwright-cli.md +++ b/plugins/aidd-qa/skills/01-acceptance-qa/references/interface-browser-playwright-cli.md @@ -10,7 +10,9 @@ Support `@playwright/cli >= 0.1.17`; execute the framework pin `@playwright/cli@ npx --yes @playwright/cli@0.1.17 -s=qa- ``` -`run-code` takes one `page` argument: pass `async page => { ... }`, never bare statements. Keep stdout, stderr, and exit status visible; no redirects, pipes, command substitutions, or `|| true`. A `SyntaxError` or non-zero exit invalidates the take. +Run every command from a temporary directory outside the application repository. + +`run-code` takes one `page` argument: pass `async page => { ... }`, never bare statements. Never throw on a product mismatch: check each expected outcome with a bounded `waitFor`, and return `{ step, expected, actual, ok }` per step. Keep stdout, stderr, and exit status visible; no redirects, pipes, command substitutions, or `|| true` in runner commands. A throw, `SyntaxError`, or non-zero exit is a tooling failure and invalidates the take. ## Recording @@ -33,14 +35,17 @@ npx --yes @playwright/cli@0.1.17 -s=qa-- run-code 'async await page.mouse.wheel(0, 600); await pause(300); await page.getByRole("button", { name: "final action" }).click(); - await page.getByText("observable expected outcome").waitFor({ state: "visible" }); + const expected = "observable expected outcome"; + const ok = await page.getByText(expected).waitFor({ state: "visible", timeout: 5000 }).then(() => true, () => false); await pause(1000); + return [{ step: "final action", expected, actual: ok ? expected : "not visible", ok }]; }' npx --yes @playwright/cli@0.1.17 -s=qa-- video-stop +# Run the teardown and verify the baseline before closing. npx --yes @playwright/cli@0.1.17 -s=qa-- close ``` -`video-stop` writes to `.playwright-cli/` in the current directory. Name raw files `raw-happy-path.webm` or `raw-edge-case-.webm`. +`video-stop` writes the raw file to the current directory. Name raw files `raw-happy-path.webm` or `raw-edge-case-.webm`. ## Duration diff --git a/plugins/aidd-refine/.claude-plugin/plugin.json b/plugins/aidd-refine/.claude-plugin/plugin.json index b462339b4..859d9b3c5 100644 --- a/plugins/aidd-refine/.claude-plugin/plugin.json +++ b/plugins/aidd-refine/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "aidd-refine", "version": "3.0.1", - "description": "Meta-cognition: refine input through brainstorming, refine output through challenge, blind-spot scanning, and fact-checking.", + "description": "Meta-cognition: refine input through brainstorming, refine output through challenge, inspect blind spots and facts, and improve conversation efficiency.", "author": { "name": "AI-Driven Dev", "url": "https://github.com/ai-driven-dev" @@ -11,12 +11,14 @@ "./skills/01-brainstorm", "./skills/02-challenge", "./skills/03-shadow-areas", - "./skills/04-fact-check" + "./skills/04-fact-check", + "./skills/05-improve" ], "keywords": [ "brainstorm", "challenge", "fact-check", + "improve", "meta-cognition", "refinement" ], diff --git a/plugins/aidd-refine/CATALOG.md b/plugins/aidd-refine/CATALOG.md index 0f48fb44d..9212c98ac 100644 --- a/plugins/aidd-refine/CATALOG.md +++ b/plugins/aidd-refine/CATALOG.md @@ -12,6 +12,7 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai - [`skills/02-challenge`](#skills02-challenge) - [`skills/03-shadow-areas`](#skills03-shadow-areas) - [`skills/04-fact-check`](#skills04-fact-check) + - [`skills/05-improve`](#skills05-improve) --- @@ -77,3 +78,14 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai | `references` | [verification-cascade.md](skills/04-fact-check/references/verification-cascade.md) | - | | `-` | [SKILL.md](skills/04-fact-check/SKILL.md) | `Verify factual claims in a text against authoritative sources and rewrite it with footnote citations, hedging the unconfirmed. Use to fact-check, verify a claim, or cite sources on request. Not for judging code or clarifying requirements.` | +#### `skills/05-improve` + +| Group | File | Description | +|-------|------|---| +| `actions` | [01-read-conversation.md](skills/05-improve/actions/01-read-conversation.md) | - | +| `actions` | [02-recommend.md](skills/05-improve/actions/02-recommend.md) | - | +| `actions` | [03-target-edits.md](skills/05-improve/actions/03-target-edits.md) | - | +| `assets` | [conversation-sources.md](skills/05-improve/assets/conversation-sources.md) | - | +| `assets` | [report.js](skills/05-improve/assets/report.js) | - | +| `-` | [SKILL.md](skills/05-improve/SKILL.md) | `Reads complete conversation evidence, measures visible cost, and recommends minimal improvements. Use when the user wants to improve a conversation, reduce wasted time or tokens, or choose files to revise. Not for delivery review or automatic edits.` | + diff --git a/plugins/aidd-refine/README.md b/plugins/aidd-refine/README.md index 02480dea4..4b3d462ef 100644 --- a/plugins/aidd-refine/README.md +++ b/plugins/aidd-refine/README.md @@ -8,7 +8,7 @@ Meta-cognition plugin for the AI-Driven Development framework. First time? Install with `/plugin install aidd-refine@aidd-framework`, then run `aidd-refine:01-brainstorm`. -Four skills that refine inputs and outputs through reflection: clarify vague requests, challenge prior work for correctness, analytically scan artifacts for blind spots, and verify factual claims against authoritative sources. +Five skills that refine inputs and outputs through reflection: clarify vague requests, challenge prior work for correctness, analytically scan artifacts for blind spots, verify factual claims, and improve completed conversations. ## Skills @@ -18,3 +18,4 @@ Four skills that refine inputs and outputs through reflection: clarify vague req | [5.2] | [challenge](skills/02-challenge/SKILL.md) | Rethink prior work to verify correctness against an agreed plan, classifying findings with a confidence score. | | [5.3] | [shadow-areas](skills/03-shadow-areas/SKILL.md) | Analytical scan of a written artifact for blind spots: each gap is classified by category and severity, paired with a direct-question probe. | | [5.4] | [fact-check](skills/04-fact-check/SKILL.md) | Verify factual claims against authoritative sources and rewrite the text with footnote citations, hedging anything that cannot be confirmed. | +| [5.5] | [improve](skills/05-improve/SKILL.md) | Read complete conversation evidence, recommend targeted improvements, and propose minimal file edits. | diff --git a/plugins/aidd-refine/skills/05-improve/SKILL.md b/plugins/aidd-refine/skills/05-improve/SKILL.md new file mode 100644 index 000000000..61f51420e --- /dev/null +++ b/plugins/aidd-refine/skills/05-improve/SKILL.md @@ -0,0 +1,36 @@ +--- +name: 05-improve +description: Reads complete conversation evidence, measures visible cost, and recommends minimal improvements. Use when the user wants to improve a conversation, reduce wasted time or tokens, or choose files to revise. Not for delivery review or automatic edits. +argument-hint: conversation | export +--- +# Improve + +```mermaid +flowchart LR + start([conversation ID or export]) --> read-conversation + read-conversation -->|complete| scopes{two or more scopes?} + read-conversation -->|unavailable| unavailable([stop]) + scopes -->|no| recommend-local[recommend locally] --> target-edits + scopes -->|yes| isolation{isolated artifact context?} + isolation -->|yes| recommend-parallel[recommend in parallel] --> target-edits + isolation -->|no| recommend-local + target-edits --> question([ask next intent]) --> stop([stop]) +``` + +## Actions + +Run the flow above. Read only the next action file. + +| Action | Does | +| --- | --- | +| read-conversation | freeze complete evidence and measure visible cost | +| recommend | analyze relevant scopes and merge grounded findings | +| target-edits | render minimal edits and an executable prompt | + +## Transversal rules + +- After resolving the source, run all three actions without pausing for confirmation. +- Analyze only a complete conversation and the exact skills or documents it names. +- Exclude every current or previous `improve` invocation and report from the analysis evidence. +- Never invent time, tokens, source coverage, or document status. +- Do not modify, stage, or persist any project file. diff --git a/plugins/aidd-refine/skills/05-improve/actions/01-read-conversation.md b/plugins/aidd-refine/skills/05-improve/actions/01-read-conversation.md new file mode 100644 index 000000000..1b146ce3a --- /dev/null +++ b/plugins/aidd-refine/skills/05-improve/actions/01-read-conversation.md @@ -0,0 +1,35 @@ +# 01 - Read conversation + +Load complete conversation evidence once and profile its visible cost. + +## Input + +A current conversation, an exact conversation ID, or a complete transcript export. + +## Output + +An evidence boundary, a `## Timing` table with `Activity | Observed time | Share | Evidence`, a `## Usage` table with `Metric | Value | Evidence`, and a scope index. + +## Process + +1. **Resolve.** Choose the host-specific transcript source from [conversation sources](../assets/conversation-sources.md). + - Stop when no complete transcript can be resolved for the exact conversation. +2. **Bound.** Freeze the evidence at the message before the current invocation, or at the supplied export boundary. +3. **Read.** Load every in-scope message, tool call, tool result, and timestamp from that transcript. +4. **Index.** Record relevant turns and invoked or named skills and knowledge files for downstream analysis. +5. **Measure.** Calculate visible time and usage only from timestamps, elapsed records, or host usage data. + - Group explicit tool activity as `research and diagnosis`, `implementation`, or `validation`; keep mixed or unknown time `unattributed`. + - Include tokens, requests, and monetary cost only when the source exposes them. + - Never label unattributed wait as reasoning time. +6. **Render.** Mark a missing metric `unavailable` and order known activity times from longest to shortest. + +## Test + +| Case | Pass | +| --- | --- | +| A conversation is analyzed | its source resolves to one exact complete transcript | +| The same conversation is analyzed again | its evidence excludes every `improve` invocation and report | +| A timing metric is shown | its evidence identifies visible timestamp or elapsed records | +| A metric is unavailable | the report does not estimate or call it reasoning time | +| A usage metric is shown | its evidence identifies the host record that exposes it | +| A scope is indexed | it identifies exact turns or artifact paths, not a generated summary | diff --git a/plugins/aidd-refine/skills/05-improve/actions/02-recommend.md b/plugins/aidd-refine/skills/05-improve/actions/02-recommend.md new file mode 100644 index 000000000..6e8f2681f --- /dev/null +++ b/plugins/aidd-refine/skills/05-improve/actions/02-recommend.md @@ -0,0 +1,50 @@ +# 02 - Recommend + +Analyze each relevant scope with the smallest grounded change. + +## Input + +The evidence boundary, timing and usage tables, complete conversation, and scope index. + +## Output + +A `## Recommendations` table with `ID | Question | Type | Diagnostic | Evidence | Smallest change | Target | Saving`. + +## Process + +1. **Scope.** Select `behavior`, plus `skill` and `knowledge` only when the scope index names relevant artifacts. +2. **Dispatch.** Analyze locally when one scope exists; otherwise dispatch one read-only analyst per scope in parallel. + - Prefer a lightweight available model and low reasoning effort when the host supports per-agent overrides; otherwise inherit the run defaults. + - Give the behavior analyst the complete frozen transcript; give artifact analysts the same boundary, indexed turns, and exact artifact paths. + - Request isolated or minimal context for artifact analysts when supported; otherwise analyze every scope locally instead of duplicating the transcript. + - Do not dispatch an analyst with no relevant evidence. + - Require table rows only and forbid file writes. +3. **Question.** Make each analyst answer every prompt for its scope. + - How could the next run be faster or better? + - What information should be removed or clarified? + - Where should the change live? + - How could it save time or tokens? + - What work was counterproductive? +4. **Verify.** Read a named skill or knowledge file before assessing its information. +5. **Assess.** Label relevant information `obsolete`, `over-specific-or-time-bound`, `duplicate`, `inconsistent`, `counterproductive`, or `correct`. + - Use `correct` when no evidence supports another label, and never render it as a recommendation. +6. **Merge.** Deduplicate findings across scopes and verify only their cited evidence against the frozen source. +7. **Render.** Order by question then `behavior`, `skill`, `knowledge`, and describe each change with the fewest unambiguous words. + - Use `skill`, `behavior`, `knowledge`, or `tooling` as the target type. + - State `time`, `tokens`, `both`, or `unknown` as its saving. + - Render `no change` when a scope has no evidence-backed recommendation. + +## Test + +| Case | Pass | +| --- | --- | +| A question is shown | it is answered from conversation evidence | +| One relevant scope exists | no parallel analyst is dispatched | +| Several relevant scopes exist | their analysts run in parallel and return the same columns | +| An artifact analyst cannot receive isolated context | every scope is analyzed locally instead of duplicating the transcript | +| A type is shown | it is `skill`, `behavior`, `knowledge`, or `tooling` | +| Information is assessed | it has one allowed label backed by evidence | +| Information is correct | its scope says `no change` and no recommendation is rendered | +| A named target is assessed | the target file was read before the verdict | +| A saving is shown | it is categorical and never an invented amount | +| The same evidence is analyzed again | recommendations keep the same order and do not cite an earlier `improve` report | diff --git a/plugins/aidd-refine/skills/05-improve/actions/03-target-edits.md b/plugins/aidd-refine/skills/05-improve/actions/03-target-edits.md new file mode 100644 index 000000000..53bc43068 --- /dev/null +++ b/plugins/aidd-refine/skills/05-improve/actions/03-target-edits.md @@ -0,0 +1,35 @@ +# 03 - Target edits + +Map recommendations to minimal edits and render the report. + +## Input + +The timing, usage, and recommendations tables. + +## Output + +An HTML report in a temporary directory with local `report.css` and `report.js`, plus its path and the next-intent question. + +## Process + +1. **Target.** Map each file-targeted recommendation to a real project path. + - Omit behavior-only recommendations from this table. +2. **Summarize.** Build a `Fichier | + Ajout | − Retrait ou clarification` table. + - Consolidate repeated targets and render each diagnostic's smallest change. + - Render one `Aucun fichier recommandé | — | —` row when no recommendation needs a file edit. +3. **Render.** Fill [the report template](../assets/report-template.html) with only measured values and grounded findings, then copy its local CSS and JavaScript beside it. + - Remove every sample value and sample finding from the produced report. + - HTML-escape every injected value; allow only template-owned markup and local asset references. + - Write only to a unique temporary directory, never the project. +4. **Return.** Provide the report path and end with this exact question: `Quel changement d’intention général, même minime, appliquons-nous au prochain run pour rendre notre amélioration cumulative et mesurable ?` + +## Test + +| Case | Pass | +| --- | --- | +| A file is shown | it is the target of an earlier recommendation | +| No file needs editing | the empty-table row is shown | +| The report is rendered | its HTML, CSS, and JavaScript resolve locally with no network dependency | +| Conversation evidence is rendered | it is escaped as text and cannot create markup, scripts, or URLs | +| A value is unavailable | the report says `unavailable` instead of showing sample data | +| The run closes | the returned chat response ends with the exact next-intent question | diff --git a/plugins/aidd-refine/skills/05-improve/assets/conversation-sources.md b/plugins/aidd-refine/skills/05-improve/assets/conversation-sources.md new file mode 100644 index 000000000..329cd7da7 --- /dev/null +++ b/plugins/aidd-refine/skills/05-improve/assets/conversation-sources.md @@ -0,0 +1,17 @@ +# Conversation sources + +Use this static routing map to load one complete conversation. Do not fill or copy it into the report. + +| Host | Preferred complete source | Session path | Timing or usage source | +| --- | --- | --- | --- | +| Codex | host thread reader with the exact thread ID, then TUI `/export` Markdown | `CODEX_HOME/history.jsonl`, normally `~/.codex/history.jsonl` | TUI `/status` estimated thread credits or cost; tool elapsed records when exposed | +| Claude Code | `/export` text, or the documented script interface for an exact session ID | `~/.claude/projects/{project}/{session-id}.jsonl` | transcript timestamps and tool records when exposed | +| OpenCode | `opencode export {session-id} --sanitize` JSON | `~/.local/share/opencode/project/{project-slug}/storage/` | `opencode stats`; `~/.local/share/opencode/log/` | + +## Rules + +- Prefer the complete export or host reader over direct storage parsing. +- Use direct storage only after matching one exact session ID. +- Search shared history or logs only for the exact session ID and ignore unrelated records. +- Never enumerate unrelated sessions or inspect configuration or authentication files. +- Treat private reasoning duration as unavailable unless the host exposes it directly. diff --git a/plugins/aidd-refine/skills/05-improve/assets/report-template.html b/plugins/aidd-refine/skills/05-improve/assets/report-template.html new file mode 100644 index 000000000..0c5300815 --- /dev/null +++ b/plugins/aidd-refine/skills/05-improve/assets/report-template.html @@ -0,0 +1,209 @@ + + + + + + + + Improve report · AIDD + + + + + + +
+
+

Conversation actuelle · couverture partielle

+

Rapport d’amélioration

+

+ Les changements les plus courts qui rendront le prochain run plus rapide, + plus fiable et moins coûteux. +

+
+

Généré12 septembre 2026 · 10:42

+

SourceConversation + fichiers cités

+

Findings3 recommandations

+
+
+ +
+
+

Mesure

Coût de la session

+ Données partielles +
+
+
Durée active24 minmesurée
+
Tokens128,4 kentrée + sortie + cache
+
Requêtes42tous outils
+
Coût monétaireInconnunon exposé par le fournisseur
+
+

Source : aidd telemetry report v8 · 2026-09-12T02:00Z → 2026-09-12T03:00Z

+
+ +
+
+

Chronologie

Où le temps est parti

+ 24 min au total +
+
    +
  1. 01
    Recherche et diagnostic12 min · 50 %
    100 %
  2. +
  3. 02
    Implémentation8 min · 33 %
    66 %
  4. +
  5. 03
    Validation4 min · 17 %
    33 %
  6. +
+
+ +
+
+

Décisions

Changements recommandés

+

3 recommandations

+
+ +
+
+ Diagnostic + + + + + + +
+
+ Cible + + + + + +
+
+ +
+
+
+
F-001
+
+
Plus rapideObsolèteToolingMettre à jour
+

La version flottante casse le reload

+
+

ÉconomieTemps

+ +
+
+
+

Pourquoi

+

latest pointe désormais vers une CLI dont la commande framework build n’existe plus.

+

Preuve

+

Commande observée : error: unknown command 'build'

+
+
+
scripts/dev-sync.sh
+
−AIDD_CLI_VERSION="${AIDD_CLI_VERSION:-latest}"
++AIDD_CLI_VERSION="${AIDD_CLI_VERSION:-5.2.2}"
+
+
+
+ +
+
+
F-002
+
+
Économiser des tokensDoublonSkillFusionner
+

Deux règles décrivent le même contrôle

+
+

ÉconomieTokens

+ +
+
+
+

Pourquoi

+

Le routeur et l’action répètent le même invariant. Une seule source suffit.

+

Preuve

+

Même exigence relevée dans SKILL.md:24 et actions/02-recommend.md:31.

+
+
+
skills/05-improve/SKILL.md
+
−Verify every recommendation against evidence.
+ The recommend action owns evidence verification.
+
+
+
+ +
+
+
F-003
+
+
Supprimer ou clarifierIncohérentKnowledgeClarifier
+

La documentation promet un coût indisponible

+
+

ÉconomieTemps

+ +
+
+
+

Pourquoi

+

Le montant exact n’est pas exposé. Le rapport doit distinguer valeur inconnue et zéro.

+

Preuve

+

cost_micro_usd absent des observations de cette session.

+
+
+
aidd_docs/memory/telemetry.md
+
−Le coût de chaque session est affiché.
++Le coût est affiché lorsqu’il est mesuré ; sinon : Inconnu.
+
+
+
+
+ +
+ +
+

Synthèse

Fichiers ciblés

3 fichiers
+
+ + + + + + + +
Fichier+ Ajout− Retrait ou clarification
scripts/dev-sync.shÉpingler une version compatibleRetirer latest
skills/05-improve/SKILL.mdConserver un propriétaireRetirer la règle dupliquée
aidd_docs/memory/telemetry.mdExpliciter l’absence de mesureRemplacer la promesse absolue
+
+
+ +
+
+

Passage à l’action

Prompt d’exécution

+ 0 recommandation acceptée +
+

Acceptez les changements à appliquer, ajustez le texte si nécessaire, puis copiez-le dans votre conversation avec l’IA.

+ +
+ Le texte reste éditable. + +
+
+
+ + diff --git a/plugins/aidd-refine/skills/05-improve/assets/report.css b/plugins/aidd-refine/skills/05-improve/assets/report.css new file mode 100644 index 000000000..1f5a54a28 --- /dev/null +++ b/plugins/aidd-refine/skills/05-improve/assets/report.css @@ -0,0 +1,298 @@ +:root { + --red: #dd5475; + --red-dark: color-mix(in oklch, var(--red) 72%, black); + --red-soft: color-mix(in oklch, var(--red) 12%, white); + --green: #66cc99; + --green-dark: color-mix(in oklch, var(--green) 62%, black); + --green-soft: color-mix(in oklch, var(--green) 15%, white); + --ink: oklch(0.16 0.01 25); + --ink-soft: oklch(0.38 0.01 25); + --muted: oklch(0.53 0.008 25); + --line: oklch(0.87 0.006 25); + --line-strong: oklch(0.72 0.01 25); + --surface: oklch(1 0 0); + --surface-soft: oklch(0.975 0.003 25); + --add-bg: var(--green-soft); + --add-ink: var(--green-dark); + --del-bg: var(--red-soft); + --del-ink: var(--red-dark); + --focus: oklch(0.64 0.18 250); + --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + margin: 0; + background: var(--surface); + color: var(--ink); + font-family: var(--mono); + font-size: 14px; + line-height: 1.6; +} + +button, a { font: inherit; } + +button:focus-visible, +a:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 3px; +} + +code { font-family: var(--mono); } + +.site-header { + min-height: 58px; + padding: 0 32px; + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + position: sticky; + top: 0; + z-index: 10; + background: color-mix(in oklch, var(--surface) 94%, transparent); + backdrop-filter: blur(12px); +} + +.brand { + color: var(--ink); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.brand-mark { + width: 25px; + height: 25px; + display: grid; + place-items: center; + background: var(--red); + color: var(--surface); + font-size: 13px; +} + +.header-actions { display: flex; align-items: center; gap: 16px; } +.run-id { color: var(--muted); font-size: 12px; } + +.button { + border: 1px solid var(--ink); + background: var(--ink); + color: var(--surface); + padding: 7px 12px; + cursor: pointer; +} + +.button:hover { background: var(--red); border-color: var(--red); } + +main { width: min(1120px, calc(100% - 48px)); margin: 0 auto; } + +.intro { padding: 72px 0 44px; border-bottom: 1px solid var(--ink); } +.context, .section-index { margin: 0 0 8px; color: var(--red); font-size: 12px; font-weight: 700; } +.context::before, .section-index::before { content: "# "; } + +h1, h2, h3, h4, p { text-wrap: pretty; } +h1 { max-width: 16ch; margin: 0; font-size: 44px; line-height: 1.08; letter-spacing: -0.04em; } +.lede { max-width: 68ch; margin: 22px 0 34px; color: var(--ink-soft); font-size: 16px; } + +.run-meta { margin: 0; display: flex; flex-wrap: wrap; gap: 20px 48px; } +.run-meta p { margin: 0; display: grid; gap: 2px; } +.run-meta span { color: var(--muted); font-size: 11px; } +.run-meta strong { font-size: 12px; } + +.section { padding: 52px 0 56px; border-bottom: 1px solid var(--line); } +.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 24px; } +.section-heading h2 { margin: 0; font-size: 23px; line-height: 1.2; letter-spacing: -0.035em; } +.section-total, .result-count { margin: 0; color: var(--muted); font-size: 12px; } + +.coverage { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-soft); font-size: 12px; } +.coverage::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--red); } + +.metrics { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--ink); border-bottom: 1px solid var(--line); } +.metric { min-height: 132px; padding: 20px; border-right: 1px solid var(--line); display: flex; flex-direction: column; } +.metric:last-child { border-right: 0; } +.metric > span { color: var(--muted); font-size: 11px; } +.metric strong { margin-top: auto; font-size: 25px; line-height: 1.2; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; } +.metric small { color: var(--muted); font-size: 10px; } +.metric-unknown strong { color: var(--red-dark); font-size: 18px; } +.source-note { margin: 12px 0 0; color: var(--muted); font-size: 11px; } +.source-note code { color: var(--ink-soft); } + +.timing-list { padding: 0; margin: 0; list-style: none; border-top: 1px solid var(--ink); } +.timing-list li { min-height: 74px; border-bottom: 1px solid var(--line); display: grid; grid-template-columns: 44px minmax(220px, 1fr) minmax(160px, 2fr); align-items: center; gap: 20px; } +.timing-rank { color: var(--red); font-weight: 700; } +.timing-list li > div { display: grid; } +.timing-list li > div span { color: var(--muted); font-size: 11px; } +.timing-bar { + width: 100%; + height: 7px; + border: 1px solid var(--line); + border-radius: 0; + background: var(--surface-soft); + appearance: none; +} +.timing-bar::-webkit-progress-bar { background: var(--surface-soft); } +.timing-bar::-webkit-progress-value { background: var(--red); } +.timing-bar::-moz-progress-bar { background: var(--red); } + +.filters { margin-bottom: 24px; padding: 14px 0; border-top: 1px solid var(--ink); border-bottom: 1px solid var(--line); display: flex; flex-wrap: wrap; gap: 16px 36px; } +.filter-group { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.filter-group > span { margin-right: 5px; color: var(--muted); font-size: 11px; } +.filter-group button { + border: 1px solid var(--line-strong); + border-radius: 999px; + padding: 4px 9px; + background: var(--surface); + color: var(--ink-soft); + font-size: 11px; + cursor: pointer; +} +.filter-group button:hover { border-color: var(--ink); color: var(--ink); } +.filter-group button[aria-pressed="true"] { border-color: var(--ink); background: var(--ink); color: var(--surface); } + +.findings { border-top: 1px solid var(--ink); } +.finding { border-bottom: 1px solid var(--ink); } +.finding[hidden] { display: none; } +.finding-header { padding: 26px 0 22px; display: grid; grid-template-columns: 62px minmax(0, 1fr) auto auto; gap: 20px; align-items: start; transition: padding 180ms ease-out; } +.finding-id { color: var(--red); font-weight: 700; font-size: 12px; } +.finding-title h3 { margin: 11px 0 0; font-size: 19px; line-height: 1.3; letter-spacing: -0.025em; } +.tags { display: flex; flex-wrap: wrap; gap: 6px; } +.tag { padding: 3px 7px; border: 1px solid var(--line-strong); font-size: 10px; line-height: 1.4; } +.tag-question { border-color: var(--ink); background: var(--ink); color: var(--surface); } +.tag-signal { border-color: var(--red); background: var(--red); color: var(--surface); } +.tag-action { border-color: var(--ink); color: var(--ink); } + +.finding-score { margin: 0; display: flex; gap: 24px; } +.finding-score p { margin: 0; display: grid; gap: 2px; } +.finding-score span { color: var(--muted); font-size: 9px; } +.finding-score strong { font-size: 11px; } + +.accept-toggle { + min-height: 31px; + padding: 5px 9px; + border: 1px solid var(--ink); + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--ink); + font-size: 10px; + font-weight: 700; + cursor: pointer; +} +.accept-toggle:hover { border-color: var(--red); color: var(--red-dark); } +.accept-toggle:focus-within { outline: 3px solid var(--focus); outline-offset: 3px; } +.accept-input { width: 14px; height: 14px; margin: 0; accent-color: var(--red); } +.accept-input:focus-visible { outline: 0; } + +.finding-collapse { display: grid; grid-template-rows: 1fr; transition: grid-template-rows 200ms ease-out; } +.finding-body { padding: 0 0 30px 82px; display: grid; grid-template-columns: minmax(240px, 0.8fr) minmax(360px, 1.45fr); gap: 36px; transition: padding 180ms ease-out; } +.finding-id, .finding-title, .finding-score, .finding-collapse { transition: opacity 160ms ease-out; } +.finding.is-accepted .finding-header { padding-block: 14px; } +.finding.is-accepted .finding-collapse { grid-template-rows: 0fr; } +.finding.is-accepted .finding-body { min-height: 0; padding-bottom: 0; overflow: hidden; } +.finding.is-accepted .finding-id, +.finding.is-accepted .finding-title, +.finding.is-accepted .finding-score, +.finding.is-accepted .finding-collapse { opacity: 0.48; } +.finding.is-accepted .accept-toggle { border-color: var(--red); background: var(--red-soft); color: var(--red-dark); } +.finding-copy h4 { margin: 0 0 5px; color: var(--muted); font-size: 10px; } +.finding-copy p { max-width: 62ch; margin: 0 0 18px; color: var(--ink-soft); font-size: 12px; } +.finding-copy .evidence { color: var(--ink); } + +.change { align-self: start; border: 1px solid var(--line-strong); background: var(--surface-soft); overflow: hidden; } +.change-header { min-height: 38px; padding: 8px 11px; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; gap: 16px; font-size: 10px; } +.change-header span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.copy-path { padding: 2px 0; border: 0; background: transparent; color: var(--red-dark); font-size: 9px; cursor: pointer; } +.copy-path:hover { text-decoration: underline; } +.diff { margin: 0; padding: 10px 0; overflow-x: auto; font: 11px/1.8 var(--mono); } +.diff code { display: block; min-width: max-content; } +.diff-line { display: block; padding: 0 13px; } +.diff-line b { display: inline-block; width: 18px; user-select: none; } +.diff-del { background: var(--del-bg); color: var(--del-ink); } +.diff-add { background: var(--add-bg); color: var(--add-ink); } +.diff-del b { color: var(--red-dark); } +.diff-add b { color: var(--green-dark); } +.diff-context { color: var(--muted); } +.empty-state { margin: 0; padding: 48px 0; border-bottom: 1px solid var(--ink); color: var(--muted); text-align: center; } + +.table-wrap { overflow-x: auto; border-top: 1px solid var(--ink); } +table { width: 100%; min-width: 680px; border-collapse: collapse; font-size: 12px; text-align: left; } +th { padding: 12px 14px; border-bottom: 1px solid var(--ink); color: var(--muted); font-size: 10px; font-weight: 500; } +td { padding: 16px 14px; border-bottom: 1px solid var(--line); vertical-align: top; } +th:first-child, td:first-child { padding-left: 0; } +th:last-child, td:last-child { padding-right: 0; } +td code { color: var(--red-dark); font-weight: 700; } + +.prompt-builder { margin: 64px 0; padding: 28px 0 0; border-top: 4px solid var(--red); } +.prompt-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; } +.prompt-heading p { margin: 0 0 5px; color: var(--red); font-size: 11px; font-weight: 700; } +.prompt-heading h2 { margin: 0; font-size: 22px; line-height: 1.3; letter-spacing: -0.025em; } +.prompt-heading > span, .prompt-actions > span { color: var(--muted); font-size: 11px; } +.prompt-help { max-width: 72ch; margin: 18px 0 12px; color: var(--ink-soft); font-size: 12px; } +#execution-prompt { + width: 100%; + min-height: 230px; + padding: 16px; + border: 1px solid var(--ink); + border-radius: 0; + resize: vertical; + background: var(--surface-soft); + color: var(--ink); + font: 12px/1.7 var(--mono); +} +#execution-prompt:focus { outline: 3px solid var(--focus); outline-offset: 3px; } +.prompt-actions { margin-top: 10px; display: flex; align-items: center; justify-content: space-between; gap: 20px; } + +@media (max-width: 820px) { + .metrics { grid-template-columns: repeat(2, 1fr); } + .metric:nth-child(2) { border-right: 0; } + .metric:nth-child(-n + 2) { border-bottom: 1px solid var(--line); } + .finding-header { grid-template-columns: 50px minmax(0, 1fr) auto; } + .finding-score { grid-column: 2; } + .accept-toggle { grid-column: 3; grid-row: 1 / span 2; } + .finding-body { padding-left: 70px; grid-template-columns: 1fr; } +} + +@media (max-width: 580px) { + .site-header { padding: 0 16px; } + .run-id { display: none; } + main { width: min(100% - 32px, 1120px); } + .intro { padding-top: 48px; } + h1 { font-size: 36px; } + .section-heading { align-items: start; flex-direction: column; gap: 8px; } + .metrics { grid-template-columns: 1fr; } + .metric { min-height: 105px; border-right: 0; border-bottom: 1px solid var(--line); } + .metric:last-child { border-bottom: 0; } + .timing-list li { padding: 14px 0; grid-template-columns: 30px 1fr; gap: 8px 12px; } + .timing-bar { grid-column: 2; } + .finding-header { grid-template-columns: 1fr; gap: 8px; } + .finding-score { grid-column: 1; flex-wrap: wrap; } + .accept-toggle { grid-column: 1; grid-row: auto; justify-self: start; } + .finding-body { padding-left: 0; } + .finding-id { order: -1; } + .prompt-heading { align-items: start; flex-direction: column; gap: 6px; } + .prompt-actions { align-items: stretch; flex-direction: column; } + .prompt-actions .button { align-self: flex-start; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + .finding-header, .finding-id, .finding-title, .finding-score, .finding-collapse { transition-duration: 0.001ms; } +} + +@media print { + .site-header, .filters, .copy-path, .accept-toggle, .prompt-actions { display: none !important; } + body { color: black; font-size: 11px; } + main { width: 100%; } + .intro { padding-top: 0; } + .section, .finding { break-inside: avoid; } + .finding[hidden] { display: block; } + .finding.is-accepted { display: none; } + .change { background: white; } +} diff --git a/plugins/aidd-refine/skills/05-improve/assets/report.js b/plugins/aidd-refine/skills/05-improve/assets/report.js new file mode 100644 index 000000000..72770d06f --- /dev/null +++ b/plugins/aidd-refine/skills/05-improve/assets/report.js @@ -0,0 +1,92 @@ +(() => { + const state = { signal: "all", target: "all" }; + const findings = [...document.querySelectorAll(".finding")]; + const count = document.querySelector("#result-count"); + const empty = document.querySelector("#empty-state"); + const prompt = document.querySelector("#execution-prompt"); + const acceptedCount = document.querySelector("#accepted-count"); + const promptAnchor = "Changements acceptés :"; + const closingPrompt = "Une fois le travail validé"; + + function render() { + let visible = 0; + for (const finding of findings) { + const matchesSignal = state.signal === "all" || finding.dataset.signal === state.signal; + const matchesTarget = state.target === "all" || finding.dataset.target === state.target; + finding.hidden = !(matchesSignal && matchesTarget); + if (!finding.hidden) visible += 1; + } + count.textContent = `${visible} recommandation${visible === 1 ? "" : "s"}`; + empty.hidden = visible !== 0; + } + + document.querySelectorAll("[data-filter]").forEach((button) => { + button.addEventListener("click", () => { + const kind = button.dataset.filter; + state[kind] = button.dataset.value; + document.querySelectorAll(`[data-filter="${kind}"]`).forEach((candidate) => { + candidate.setAttribute("aria-pressed", String(candidate === button)); + }); + render(); + }); + }); + + document.querySelectorAll(".copy-path").forEach((button) => { + button.addEventListener("click", async () => { + const original = button.textContent; + try { + await navigator.clipboard.writeText(button.dataset.copy); + button.textContent = "Copié"; + } catch { + button.textContent = "Copie indisponible"; + } + window.setTimeout(() => { button.textContent = original; }, 1400); + }); + }); + + function updatePrompt(changedFinding) { + const accepted = findings.filter((finding) => finding.querySelector(".accept-input").checked); + const changedInput = changedFinding.querySelector(".accept-input"); + const id = changedFinding.querySelector(".finding-id").textContent.trim(); + const prefix = `- [${id}] `; + const generatedLine = `${prefix}${changedInput.dataset.prompt}`; + const lines = prompt.value.split("\n").filter((line) => !line.startsWith(prefix)); + const anchorIndex = lines.findIndex((line) => line.trim() === promptAnchor); + + if (changedInput.checked) { + const closingIndex = lines.findIndex((line) => line.startsWith(closingPrompt)); + let insertAt = closingIndex >= 0 ? closingIndex : anchorIndex + 1; + if (lines[insertAt - 1] === "") insertAt -= 1; + lines.splice(Math.max(insertAt, 0), 0, generatedLine); + } + prompt.value = lines.join("\n"); + + const total = accepted.length; + acceptedCount.textContent = `${total} recommandation${total === 1 ? "" : "s"} acceptée${total === 1 ? "" : "s"}`; + } + + document.querySelectorAll(".accept-input").forEach((input) => { + input.addEventListener("change", () => { + const finding = input.closest(".finding"); + finding.classList.toggle("is-accepted", input.checked); + finding.querySelector(".accept-toggle span").textContent = input.checked ? "Accepté" : "Accepter"; + updatePrompt(finding); + }); + }); + + document.querySelector("#copy-prompt").addEventListener("click", async (event) => { + const button = event.currentTarget; + const original = button.textContent; + try { + await navigator.clipboard.writeText(prompt.value); + button.textContent = "Prompt copié"; + } catch { + prompt.focus(); + prompt.select(); + button.textContent = "Texte sélectionné"; + } + window.setTimeout(() => { button.textContent = original; }, 1600); + }); + + document.querySelector("#print-report").addEventListener("click", () => window.print()); +})(); diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index d6bb6f0c5..5090f6168 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -5,14 +5,13 @@ Know what a piece of work cost: which skill, which step and which task spent the tokens. > Status: beta. Proven end to end on Claude Code; the other four tools are covered to the -> extent their own files allow, see [Coverage](#coverage). Off the curated install path -> until it has run on other people's machines. +> extent their own files allow. Off the curated install path until it has run on other +> people's machines. -## What it does +## What it is Your provider can tell you a developer burned four million tokens on Tuesday. This plugin -tells you that `aidd-dev:02-implement` spent 78,188 of them, on task `2026_09_01_the-upward-link`, -inside one orchestrated flow. It attributes consumption to the framework's own units of work. +tells you which skill spent them, on which task. ```text period 2026-08-21 to 2026-08-21 @@ -27,6 +26,16 @@ period 2026-08-21 to 2026-08-21 aidd-ui:01-hello 33% 38,490 tokens from a journal interval ``` +Every figure says how it was attributed. `stated by the tool` is exact, `from a journal +interval` is an inference, `unattributed` means neither source could say — never "no step +ran". An unknown is named, never shown as a zero, and no figure is in currency: pricing +tokens is a separate service's job. + +## Why it exists + +A provider meters an account. Only the framework knows its own units of work — the skill, +the step, the task, the flow — so only it can say what one piece of work cost. + Three things it never does: it never sends anything anywhere, it never stores a prompt, a diff or a line of code, and it never records until you turn it on. @@ -51,19 +60,17 @@ flowchart LR CLI -->|joins by session, keeps a record| Store --> Answer ``` -- **The hooks journal.** While measurement is on, every session appends one line per - observation to `aidd_docs/runs/__.jsonl`, git-ignored, never rewritten. - No token, no cost, no model lands there. +- **The hooks journal.** Every session appends one line per observation to + `aidd_docs/runs/__.jsonl`, git-ignored, never rewritten. No token, no + cost, no model lands there. - **Your tool writes its own transcript**, in its own place and format. It holds the tokens and knows nothing about skills. -- **`aidd telemetry report` joins the two.** It reads the transcript, normalises it into one - shape whatever tool produced it, matches each record against the journal and keeps the - result under `~/.config/aidd/telemetry/`. The join cannot happen live: when a hook fires, - the tokens for that turn are not written yet. +- **`aidd telemetry report` joins the two**, by session, and keeps the result under + `~/.config/aidd/telemetry/`. The join cannot happen live: when a hook fires, the tokens + for that turn are not written yet. -Recording is the one act that depends on nothing: the hooks run under plain `node`, so a -session is measured whether or not `aidd` is installed. Allowing and answering go through -the CLI, so the figure is computed once, in one place, whatever asked for it. +Recording depends on nothing but `node`, so a session is measured whether or not `aidd` is +installed. Allowing and answering go through the CLI, so the figure is computed once. ## Getting started @@ -72,160 +79,65 @@ npm install -g @ai-driven-dev/cli aidd plugin install aidd-telemetry ``` -Then talk to your AI tool. Each skill reaches the CLI and stops with the reason if `aidd` -does not answer, rather than reporting an empty figure. +Then ask your AI tool for a skill. Each one stops with the reason if `aidd` does not answer, +rather than reporting an empty figure. | Ask your tool for | It runs | You get | | --- | --- | --- | -| `00-init` | `aidd telemetry on`, then `check` | measurement allowed for this project, and proof the switch took | +| `00-init` | `aidd telemetry on`, then reads a run file back | measurement allowed for this project, and proof a session is journalled | | `01-cost` | `aidd telemetry report` | what a period or one task consumed, by step, model, task, flow, tool or person | | `02-check` | `aidd telemetry check` | whether the chain is actually recording, and what to fix if not | -Your tool's own plugin mechanism installs the plugin just as well. The CLI stays required -for one reason: nothing recorded can be read without it. - -```mermaid -sequenceDiagram - participant You - participant Tool as AI tool - participant Hook as journal.cjs - participant CLI as aidd telemetry - You->>Tool: ask for 00-init - Tool->>CLI: telemetry on - Note over CLI: .aidd/config.json telemetry.enabled = true
aidd_docs/runs/ git-ignored - Tool->>Hook: SessionStart - Hook->>Hook: session_start line - loop every turn - Tool->>Hook: PostToolUse - Hook->>Hook: step_start, task_declared, file_written - Tool->>Hook: Stop - Hook->>Hook: turn_end line - end - You->>Tool: ask for 01-cost - Tool->>CLI: telemetry report - CLI->>CLI: read transcript + journal, join by session - CLI-->>Tool: figures per step and task -``` - -## What a figure tells you about itself - -Every attributed figure says how it was attributed, because the two ways are not the same -claim. - -| Reads | Means | -| --- | --- | -| stated by the tool | the tool named the running skill itself, on the line with the counters: exact | -| from a journal interval | derived from the interval between two boundaries the journal recorded: an inference | -| unattributed | neither source could say. It never means "no step ran" | - -An absent figure is named, never shown as a zero. A tool that cannot be read, one that -carries no amount, one that measured nothing and one whose reader failed are four -different answers. No tool read locally writes a figure in currency: reports give tokens, -and turning tokens into money is a separate service's job. - ## Coverage | Tool | Tokens | Step | Task | | --- | --- | --- | --- | | **Claude Code** | ✅ proven on live sessions | ✅ stated by the tool, and by interval | ✅ | | **Codex** | ✅ on captured rollouts | ✅ by interval | ✅ | -| **OpenCode** | ✅ | ✅ through its own plugin API | ✅ | -| **Copilot** | ⚠️ session total only, no per-request figure (one cumulative total at shutdown) | ✅ by interval ([#663](https://github.com/ai-driven-dev/framework/issues/663)) | ✅ | +| **OpenCode** | ✅ | ❌ no skill call reaches its plugin | ✅ | +| **Copilot** | ⚠️ session total only, no per-request figure (one cumulative total at shutdown) | ✅ by interval | ✅ | | **Cursor** | ❌ no token count in any file it writes | ✅ | ✅ | -
-Measured limits, per tool +A limit a reader has to look up gets read as a zero, so each one is named here: - **Codex needs one interactive approval.** Its hook trust is per entry and a headless run - never sees the prompt, so a Codex session journals nothing until someone approves once, - in an interactive session. -- **OpenCode never announces a session, so the plugin opens it.** Measured on one live - `opencode 1.14.20` run (2026-08-31, `--print-logs`): `session.created` is published on - the bus but never reaches the hook, while `session.idle` reaches every session. The first - call a session produces therefore opens it, under the directory that call was going to - use. What is lost: on a server serving several directories, a session it never announced - is journalled under the plugin's own init-time directory. Details in - `scripts/__tests__/fixtures/README.md`, "OpenCode's plugin events". -- **A task is declared from a tool call's own arguments, on every host.** A `Read`, a `Bash` - command line or an object keyed `path` naming a file under a task folder is enough; the - journal reads that text rather than asking the host to cooperate. OpenCode joined on - 2026-08-31 once a completed tool part's arguments were measured to reach its `event` - hook. One real capture per host is kept in `scripts/__tests__/fixtures/README.md`. -- **These are raw counters, not your tool's usage screen.** A vendor's page weights a - cached token by what it charges for it; these figures are the counts the tool wrote down. - The two disagree on cache lines by construction, and neither is wrong. -- **A period means when the work ran**, not when it was billed. -- **A sweep reaches a session only where the journal was installed.** Work done before you - turned measurement on is not there, and nothing reconstructs it. - -
+ never sees the prompt, so a Codex session journals nothing until someone approves once. +- **OpenCode never announces a session, so the plugin opens it.** `session.created` is + published on its bus but never reaches the hook, so the first call a session produces + opens it, under the directory that call was going to use. What is lost: on a server + serving several directories, a session it never announced is + journalled under the plugin's own init-time directory. +- **OpenCode never names a step.** Its plugin forwards task paths and nothing else, so no + skill invocation reaches the journal and every OpenCode request is unattributed by step. +- **These are raw counters, not your tool's usage screen.** A vendor's page weights a cached + token by what it charges for it; these are the counts the tool wrote down. The two + disagree on cache lines by construction, and neither is wrong. +- **A period means when the work ran**, not when it was billed, and nothing reconstructs + work done before you turned measurement on. ## Privacy -- **Nothing leaves the machine.** Every code path that once could, the export writer, its - endpoint, the server it talked to, is deleted. Everything measured is read back from - where it was written. On a machine where an older version once configured an export - endpoint, `aidd telemetry check` and `aidd telemetry off` both detect the settings file - it left and name what to remove by hand. +- **Nothing leaves the machine.** Every code path that once could is deleted. On a machine + where an older version configured an export endpoint, `aidd telemetry check` and + `aidd telemetry off` both detect it and name what to remove by hand. - **No prompt, no code, no diff.** The stored shape is an allowlist, field by field, in [the record contract](../../aidd_docs/product/metrics-contract.md). - **The switch is a file you commit or do not**, per project (`.aidd/config.json`). Once committed on, it applies to everyone who clones. Refuse it for yourself alone with `AIDD_TELEMETRY=0`, which overrides the file unconditionally. -- **`off` keeps what you measured.** It stops the recording, not the record. - **`aidd telemetry forget` removes it**: this project's journal, this machine's stored - records (every project ever measured on this machine) and this machine's identity file. - It shows what would go before anything happens and removes nothing without `--yes`. -- **Your identity is yours to attach.** `aidd telemetry identity` opts a person in or out - of naming themselves on their own records; nothing about a colleague's identity arrives on - its own. - -## Where things live - -| What | Where | Why there | -| --- | --- | --- | -| The switch | `.aidd/config.json` in the project | a property of the repository, shared by committing it | -| The journal | `aidd_docs/runs/` in the project, git-ignored | every line names a repository-relative path or a task folder | -| The figures | `~/.config/aidd/telemetry/` (`%APPDATA%\aidd\telemetry\` on Windows), or `AIDD_TELEMETRY_DIR` | a session's consumption belongs to the person who ran it | -| The identity | the OS profile, on every platform | it never follows a shared directory | - -**Share `AIDD_TELEMETRY_DIR`, never `AIDD_USER_CONFIG_DIR`.** Point the first at a directory -a team shares and every figure follows it. The second relocates a machine's whole AIDD -configuration, `auth.json` and its GitHub token included, and two people pointing at one -directory would overwrite each other's token file. A setup made before this split still -works through `AIDD_USER_CONFIG_DIR`, and should be changed. - -On a shared sink, a colleague's records stay `unresolved` in your report until you run -`aidd telemetry identity link` on their identifier yourself. Linking declares "this -identifier is me", and the CLI cannot check that claim against anything the colleague wrote. - -## The backlog link - -A task folder can say which backlog item it delivers. `aidd-pm:04-spec` and -`aidd-dev:01-plan` write `backlog-link.json` beside `spec.md` and `plan.md` when the request -they build from names one. No file is the normal state, never an error. - -```json -{ - "backlog": "owner/repo#123", - "written_at": "2026-08-21T09:00:00Z", - "written_by": "aidd-pm:04-spec" -} -``` - -`backlog` names the item wherever it lives, a forge reference or a project-relative -Markdown path, one field for both per -[`persistence.md`](../aidd-pm/skills/10-task/references/persistence.md). `written_at` and -`written_by` are provenance, not status. It is a plain file: edit `backlog` and the next -report reads it as it stands. Nothing here re-derives or overwrites a declaration that -exists. It carries no steps, no file list, no branch and no status: the journal, git and -the artefacts' own frontmatter already own those. +- **`off` keeps what you measured**; `aidd telemetry forget` removes it — this project's + journal, this machine's records and its identity file — and removes nothing without + `--yes`. +- **Your identity is yours to attach**, through `aidd telemetry identity`. To share figures + across a team, point `AIDD_TELEMETRY_DIR` at a shared directory — never + `AIDD_USER_CONFIG_DIR`, which also relocates `auth.json` and its GitHub token. ## Where things are written down - [`aidd_docs/runs/README.md`](../../aidd_docs/runs/README.md): what the journal records, and what it deliberately does not. - [`cost-report-contract.md`](../../aidd_docs/product/cost-report-contract.md): the object - `report --json` prints, for a skill or a program to consume. -- [`metrics-contract.md`](../../aidd_docs/product/metrics-contract.md): one stored line, - for a service that prices them. + `report --json` prints, and the `backlog-link.json` a task folder may carry to say which + backlog item it delivers. +- [`metrics-contract.md`](../../aidd_docs/product/metrics-contract.md): one stored line, for + a service that prices them. diff --git a/plugins/aidd-telemetry/hooks/lib/file-writes.cjs b/plugins/aidd-telemetry/hooks/lib/file-writes.cjs index 40bf62fcf..84f2f48eb 100644 --- a/plugins/aidd-telemetry/hooks/lib/file-writes.cjs +++ b/plugins/aidd-telemetry/hooks/lib/file-writes.cjs @@ -104,7 +104,7 @@ function handleFileWritten(payload, host, sessionId) { const stated = statedRawPath(payload, host); if (!stated) return; - const target = resolveRunsDir(payload.cwd); + const target = resolveRunsDir(readCwd(host, payload)); if (!target) return; const relativePath = taskFolderRelativePath(target.repoRoot, realPathOf(stated)); diff --git a/plugins/aidd-telemetry/hooks/opencode-plugin.js b/plugins/aidd-telemetry/hooks/opencode-plugin.js index 2b9c51679..26d30144b 100644 --- a/plugins/aidd-telemetry/hooks/opencode-plugin.js +++ b/plugins/aidd-telemetry/hooks/opencode-plugin.js @@ -16,7 +16,13 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -const JOURNAL_SCRIPT = fileURLToPath(new URL("./journal.cjs", import.meta.url)); +// Not a sibling: OpenCode's loader scans `plugin/` one level deep, so the build delivers this +// module there alone and every other hook script under `hooks//`. That is the same +// `../hooks//` the generated bridge resolves (opencode-hooks-bridge.ts's HOOKS_DIR), +// and build.unit.test.ts checks this literal against where the build actually writes it. +const JOURNAL_SCRIPT = fileURLToPath( + new URL("../hooks/aidd-telemetry/journal.cjs", import.meta.url) +); // Never `process.execPath`: OpenCode ships as its own standalone binary, so that path names // `opencode` itself, not a Node runtime that can run journal.cjs. diff --git a/plugins/aidd-vcs/CATALOG.md b/plugins/aidd-vcs/CATALOG.md index fed85e299..93dd51901 100644 --- a/plugins/aidd-vcs/CATALOG.md +++ b/plugins/aidd-vcs/CATALOG.md @@ -41,7 +41,7 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai | `actions` | [02-message.md](skills/01-commit/actions/02-message.md) | - | | `actions` | [03-commit.md](skills/01-commit/actions/03-commit.md) | - | | `assets` | [commit-template.md](skills/01-commit/assets/commit-template.md) | `VCS commit message template` | -| `-` | [SKILL.md](skills/01-commit/SKILL.md) | `Create an atomic git commit with a conventional message, optionally pushing. Use when the user wants to commit changes, optionally pushing the branch. Not for amending, rebasing, opening a pull request, or tagging a release.` | +| `-` | [SKILL.md](skills/01-commit/SKILL.md) | `Create atomic conventional commits; safely retry scoped hook fixes; optionally push. Use when the user wants to commit changes, optionally pushing the branch. Not for amending, rebasing, opening a pull request, or tagging a release.` | #### `skills/02-pull-request` diff --git a/plugins/aidd-vcs/README.md b/plugins/aidd-vcs/README.md index 75e4898df..55566ca53 100644 --- a/plugins/aidd-vcs/README.md +++ b/plugins/aidd-vcs/README.md @@ -17,7 +17,7 @@ Covers all external artifact creation: repo init, commits, pull/merge requests, | Bracket ID | Skill | Description | |---|---|---| | [3.0] | [repo-init](skills/00-repo-init/SKILL.md) | Initialize a repo: git init, default branch, bootstrap commit, CONTRIBUTING.md, optional remote. | -| [3.1] | [commit](skills/01-commit/SKILL.md) | Create a git commit with proper conventional message format. | +| [3.1] | [commit](skills/01-commit/SKILL.md) | Commit atomically; safely retry scoped hook fixes. | | [3.2] | [pull-request](skills/02-pull-request/SKILL.md) | Create PR (GitHub) or MR (GitLab) with filled template. | | [3.3] | [release-tag](skills/03-release-tag/SKILL.md) | Create and push a semantic version git tag with release notes. | | [3.4] | [issue-create](skills/04-issue-create/SKILL.md) | Create issues in the configured ticketing tool. | diff --git a/plugins/aidd-vcs/skills/01-commit/SKILL.md b/plugins/aidd-vcs/skills/01-commit/SKILL.md index ea1286d44..98ecd3533 100644 --- a/plugins/aidd-vcs/skills/01-commit/SKILL.md +++ b/plugins/aidd-vcs/skills/01-commit/SKILL.md @@ -1,20 +1,20 @@ --- name: 01-commit -description: Create an atomic git commit with a conventional message, optionally pushing. Use when the user wants to commit changes, optionally pushing the branch. Not for amending, rebasing, opening a pull request, or tagging a release. +description: Create atomic conventional commits; safely retry scoped hook fixes; optionally push. Use when the user wants to commit changes, optionally pushing the branch. Not for amending, rebasing, opening a pull request, or tagging a release. argument-hint: paths | auto | push --- # Commit -Stage the right changes, write the message, commit. `01 → 02 → 03`. +Stage, message, commit. `01 → 02 → 03`. ## Actions -| # | Action | Step | -| --- | --------- | ----------------------------------------------------- | -| 01 | `collect` | Review the change and stage what belongs in one commit | -| 02 | `message` | Write the conventional message | -| 03 | `commit` | Commit, and push when asked | +| # | Action | Step | +| --- | --------- | ---------------------------------------------- | +| 01 | `collect` | Stage one concern | +| 02 | `message` | Write the conventional message | +| 03 | `commit` | Commit, safely retry scoped fixes, push if asked | Several concerns means several commits: repeat the chain, one concern at a time. Before running an action, read its file in `actions/`, not only the table or assets. @@ -25,7 +25,7 @@ Before running an action, read its file in `actions/`, not only the table or ass - One concern per commit. Imperative mood. The body says why, not what. - Reference the issue in the body when there is one. - Never `--force` push; `--force-with-lease` only when explicitly asked. -- A hook that rejects the commit is not this skill's job: report which hook and why, then stop. Re-stage only files a hook auto-formatted. +- Retry a rejected hook only for deterministic changes within the current commit's files. Never broaden the change to make a check pass. - `auto` never prompts. `interactive` confirms before staging and before each split. - Commits locally by default; pushes as well only when the push option is set. diff --git a/plugins/aidd-vcs/skills/01-commit/actions/03-commit.md b/plugins/aidd-vcs/skills/01-commit/actions/03-commit.md index 49d2a7aa0..2b7cabce2 100644 --- a/plugins/aidd-vcs/skills/01-commit/actions/03-commit.md +++ b/plugins/aidd-vcs/skills/01-commit/actions/03-commit.md @@ -1,6 +1,6 @@ # 03 - Commit -Record the commit, and push when asked. +Record the commit, safely retry scoped hook fixes, and push when asked. ## Input @@ -8,16 +8,18 @@ The staged set from `01-collect`, the message from `02-message`, and whether to ## Output -The commit sha, the branch, and whether it was pushed. +The commit sha, branch, correction count, and push outcome. ## Process 1. **Commit.** Run `git commit` with the message. -2. **Hook.** If a pre-commit hook rejects it, report which hook and why, then stop; fixing it is the caller's job. Re-stage and retry once only when the hook merely auto-formatted files. +2. **Retry.** On hook rejection, re-stage and retry only a deterministic correction within the current commit's files. Stop and report the hook and error when it needs judgment, leaves scope, makes no progress, or fails three times. 3. **Push.** When asked, push the branch. Use `--force-with-lease` only when explicitly required, never `--force`. +4. **Report.** Return the short sha, subject, file count, correction count, and push outcome. ## Test - `git rev-parse HEAD` returns the new sha and its message matches the project convention. -- A rejecting hook leaves no commit and a clear report, not a retry loop. -- When pushed, the remote branch shows the sha. +- Every correction and re-staged file belongs to the current commit's staged files. +- An ambiguous, out-of-scope, no-progress, or third failed correction leaves no commit and reports the blocker. +- When pushed, the remote branch shows the final sha. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24ff1ec21..50b15978f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,11 +122,11 @@ importers: specifier: 3.0.1 version: 3.0.1(ajv@8.20.0) js-yaml: - specifier: 5.4.1 - version: 5.4.1 + specifier: 5.4.2 + version: 5.4.2 lefthook: - specifier: ^2.1.12 - version: 2.1.12 + specifier: ^2.1.14 + version: 2.1.14 packages: @@ -359,8 +359,8 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true - js-yaml@5.4.1: - resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} + js-yaml@5.4.2: + resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} hasBin: true json-parse-even-better-errors@2.3.1: @@ -369,58 +369,58 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - lefthook-darwin-arm64@2.1.12: - resolution: {integrity: sha512-GSUjqaCuxAYlPOovbjXEWE3HAIa/xOQkTTmZ937zieQldjPLTaC7+s5FHoxKywn+D9riBlofkDqG7Z4f5zzmPA==} + lefthook-darwin-arm64@2.1.14: + resolution: {integrity: sha512-VMUAqY81+8ph7Mc0iROd3JWqj2sOuK7SYDyd8ZgzYVKChCt+Nnz8ehd4gUwprJy7QI28IFSUh1M3wnIRiYh+fA==} cpu: [arm64] os: [darwin] - lefthook-darwin-x64@2.1.12: - resolution: {integrity: sha512-qAUHSSw/7Afi5wxkoLkxORxSicCeTBXyVLnRgD6YZra6udviozpK3Aok9Z6FIWeKc5FBijRMSNDxGPTREhsjcQ==} + lefthook-darwin-x64@2.1.14: + resolution: {integrity: sha512-uSmjcPRU+yB+D6vk+u6WJDnyHlB+XIv1QZw97N2+qNGG2bNSLH/7MuJ6puCdJv+lVPBEO5wBjE3JIbaEzPOQCw==} cpu: [x64] os: [darwin] - lefthook-freebsd-arm64@2.1.12: - resolution: {integrity: sha512-FHZRfKliFNbyz54zsoGdVe9muq67cNjBTZ6jrPPUjI7xUuyCwSpzA+1OpiwJT00L9pP5ZGONBqnGZKnrpC+7Uw==} + lefthook-freebsd-arm64@2.1.14: + resolution: {integrity: sha512-po/pmjbp/7BjE9RFfeDnh+CeNsA0d+utppOYKB425I7mgI1GcmT8GAZ9DDut4DIutNjVqKL/lzrj+F87Gm40Rg==} cpu: [arm64] os: [freebsd] - lefthook-freebsd-x64@2.1.12: - resolution: {integrity: sha512-HYQZPGy2DDEx7dbuotusJpujY5q9igOLgx36qeeQcNQuvvdGHPj2ZGSIsGKhoJ0dw5Qa4knKJoPAHEZQWdV/og==} + lefthook-freebsd-x64@2.1.14: + resolution: {integrity: sha512-+KiXiFjJf7YdHUYjzhDsklcA5bAFZN+pNcz/NbBftrhVTwzNALNoK+SYEKt+9QJsrzL6tpDWmHHFpeLGp+8t8w==} cpu: [x64] os: [freebsd] - lefthook-linux-arm64@2.1.12: - resolution: {integrity: sha512-ipjOri2PB/sk4noPrHQuM5fcpNBjmlKo4qMtXyLnxeOxGYHWZzf0Xi0OtrXHQ//tOprcv40ADvhUuSdcGqigLw==} + lefthook-linux-arm64@2.1.14: + resolution: {integrity: sha512-bjeJB16ftJaPWGTedssh8ZrqRy1ACfQuTV7a0O3NU1FoIsJtfHOm0o0mV4cPf4q+ZCVCwq1Xf2I11qUpuJFV4Q==} cpu: [arm64] os: [linux] - lefthook-linux-x64@2.1.12: - resolution: {integrity: sha512-DmU6xfvqVoFdW+STyc70YI4Ry8vkHGKHx+IJ1Js/Gacq5dv+fiwNzwle3bi28EkL6P8xY67B/CfQfRj4SRU4tg==} + lefthook-linux-x64@2.1.14: + resolution: {integrity: sha512-gYExzjU2w3uKrP1MklbHENS1cXUdCQRIiEJkykI7q4RjhIHi+mQEUAZLxZw93a6jE2pNWaO0me5tB8EbEt4/GQ==} cpu: [x64] os: [linux] - lefthook-openbsd-arm64@2.1.12: - resolution: {integrity: sha512-vb0J7bElLvoaCWQmna3HntsvoNqMsGAhfjjC99ss1OdCteufZKM3RxCVoMBEbD50Itv2c1Ua2AFVToltVFTy1Q==} + lefthook-openbsd-arm64@2.1.14: + resolution: {integrity: sha512-MCW76gTlEMBF3Ds8O63UTxjaSguha/5zT0U3Vr719I1sXueSpttfRmm/5YfIB4MygB116yA/Vi+DsizT/X0kcg==} cpu: [arm64] os: [openbsd] - lefthook-openbsd-x64@2.1.12: - resolution: {integrity: sha512-QOmhDWqPPK4+99scanfEmbJjUR+JYC93qhr/0bp5Dj3LaR3kVFNWc6zOQUsOTPy/LC6PG759Lej/mr/BtjUL2Q==} + lefthook-openbsd-x64@2.1.14: + resolution: {integrity: sha512-salAc1PAhLI6xrB9pth5UxALQlTOGvZAfaAvgFwixhkdu8D//uW8zS3faFq31ktd1bDAy5wDI8p9yg+fF5+2uA==} cpu: [x64] os: [openbsd] - lefthook-windows-arm64@2.1.12: - resolution: {integrity: sha512-eErEyr9AHkRFj6TxpCQeKGd0s6IrtL/VEIZ/ssg8dNfG+ycR4jAW/IAlrJSw6/ZQXb3WtWLaQ6QA5c+TLh7vmg==} + lefthook-windows-arm64@2.1.14: + resolution: {integrity: sha512-q3JC6lBrYLzkhXEhfnmFxJF47gSd5JeDweH3aNrHoLGX/KkRiOrk9RSyoRwSuGq03EYxSkJhl1FLj2GUxF7VqQ==} cpu: [arm64] os: [win32] - lefthook-windows-x64@2.1.12: - resolution: {integrity: sha512-0h+WmDVdDriTjloD/UbjPq/ZtqaaJlPAdGcVwMCEdAxfbF0FTgDbKX3igui9g2U/qG2y6QpVhtz1jeiRe7ctaw==} + lefthook-windows-x64@2.1.14: + resolution: {integrity: sha512-WwIxdwW1lDAaJUU3ETcKlCtdqluGr3Cy+LjKvP4QIQnFf0FEXL78THstIkZ6k6qhfUn7/WvyqSIf0cbTAKw2nQ==} cpu: [x64] os: [win32] - lefthook@2.1.12: - resolution: {integrity: sha512-2uOexfsrrRhCiTUWdGyDySxVHHhOFJ8MQejs27dM3hugrQB48/z1ZVlaNoxpZ1SnzQ1GaDIbO5rAvicwyiknYQ==} + lefthook@2.1.14: + resolution: {integrity: sha512-Y9TL1dmpRlIdSp73SJ6bb+xAZ4s2SinGTK3pgC9PRWMSjMr2iOfowPGnlSOfAo5fu6S5rdiItsQDxeLEWCGk4g==} hasBin: true lines-and-columns@1.2.4: @@ -732,7 +732,7 @@ snapshots: dependencies: argparse: 2.0.1 - js-yaml@5.4.1: + js-yaml@5.4.2: dependencies: argparse: 2.0.1 @@ -740,48 +740,48 @@ snapshots: json-schema-traverse@1.0.0: {} - lefthook-darwin-arm64@2.1.12: + lefthook-darwin-arm64@2.1.14: optional: true - lefthook-darwin-x64@2.1.12: + lefthook-darwin-x64@2.1.14: optional: true - lefthook-freebsd-arm64@2.1.12: + lefthook-freebsd-arm64@2.1.14: optional: true - lefthook-freebsd-x64@2.1.12: + lefthook-freebsd-x64@2.1.14: optional: true - lefthook-linux-arm64@2.1.12: + lefthook-linux-arm64@2.1.14: optional: true - lefthook-linux-x64@2.1.12: + lefthook-linux-x64@2.1.14: optional: true - lefthook-openbsd-arm64@2.1.12: + lefthook-openbsd-arm64@2.1.14: optional: true - lefthook-openbsd-x64@2.1.12: + lefthook-openbsd-x64@2.1.14: optional: true - lefthook-windows-arm64@2.1.12: + lefthook-windows-arm64@2.1.14: optional: true - lefthook-windows-x64@2.1.12: + lefthook-windows-x64@2.1.14: optional: true - lefthook@2.1.12: + lefthook@2.1.14: optionalDependencies: - lefthook-darwin-arm64: 2.1.12 - lefthook-darwin-x64: 2.1.12 - lefthook-freebsd-arm64: 2.1.12 - lefthook-freebsd-x64: 2.1.12 - lefthook-linux-arm64: 2.1.12 - lefthook-linux-x64: 2.1.12 - lefthook-openbsd-arm64: 2.1.12 - lefthook-openbsd-x64: 2.1.12 - lefthook-windows-arm64: 2.1.12 - lefthook-windows-x64: 2.1.12 + lefthook-darwin-arm64: 2.1.14 + lefthook-darwin-x64: 2.1.14 + lefthook-freebsd-arm64: 2.1.14 + lefthook-freebsd-x64: 2.1.14 + lefthook-linux-arm64: 2.1.14 + lefthook-linux-x64: 2.1.14 + lefthook-openbsd-arm64: 2.1.14 + lefthook-openbsd-x64: 2.1.14 + lefthook-windows-arm64: 2.1.14 + lefthook-windows-x64: 2.1.14 lines-and-columns@1.2.4: {} diff --git a/release-please-config.json b/release-please-config.json index 6d2808f58..7d7526731 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -104,6 +104,17 @@ } ] }, + "plugins/aidd-qa": { + "package-name": "aidd-qa", + "release-as": "1.0.0", + "extra-files": [ + { + "type": "json", + "path": ".claude-plugin/plugin.json", + "jsonpath": "$.version" + } + ] + }, "cli": { "release-type": "node", "package-name": "@ai-driven-dev/cli" diff --git a/scripts/__tests__/aidd-telemetry-file-writes.test.js b/scripts/__tests__/aidd-telemetry-file-writes.test.js index 93bdf4a84..a546bdeda 100644 --- a/scripts/__tests__/aidd-telemetry-file-writes.test.js +++ b/scripts/__tests__/aidd-telemetry-file-writes.test.js @@ -137,3 +137,96 @@ test("tells the run file what it skipped, rather than reading as complete covera assert.ok(fileWrittenCount < MAX_SCAN_ENTRIES + 300, "found every file despite the cap"); fs.rmSync(repo, { recursive: true, force: true }); }); + +// No host declared today can witness a repository resolved from payload.cwd instead of the +// host: the four naming no written path return before that line, and the one that names a +// path also carries cwd. A host is a table entry, so the witness is one more entry. +const TOOLS_INDEX = path.join(root, "plugins/aidd-telemetry/hooks/lib/tools/index.cjs"); + +const WORKSPACE_HOST = "probe-workspace-host"; + +const workspaceHostTool = { + readSessionId: (payload) => payload.conversationId, + readCwd: (payload) => payload.workspacePaths[0], + vendorField: null, + stepStart: { skillName: () => null, turnIdField: null }, + writtenPath: (payload) => payload.toolCall.target, +}; + +// file-writes.cjs and record.cjs destructure the table at require time, so the entry goes in +// before they are loaded and both are dropped again on the way out. +function withWorkspaceHost(run) { + const indexId = require.resolve(TOOLS_INDEX); + const realExports = require(TOOLS_INDEX); + const tools = Object.freeze({ ...realExports.TOOLS_BY_HOST, [WORKSPACE_HOST]: workspaceHostTool }); + const toolFor = (host) => tools[host] || null; + require.cache[indexId].exports = { + ...realExports, + TOOLS_BY_HOST: tools, + toolFor, + readCwd: (host, payload) => (toolFor(host) ? toolFor(host).readCwd(payload) : undefined), + readSessionId: (host, payload) => (toolFor(host) ? toolFor(host).readSessionId(payload) : undefined), + }; + delete require.cache[require.resolve(FILE_WRITES)]; + delete require.cache[require.resolve(RECORD)]; + try { + return run({ fileWrites: require(FILE_WRITES), record: require(RECORD) }); + } finally { + require.cache[indexId].exports = realExports; + delete require.cache[require.resolve(FILE_WRITES)]; + delete require.cache[require.resolve(RECORD)]; + } +} + +test("records the write a host stated when that host names its workspace rather than a cwd", () => { + // realpath: the stated path is resolved through realpathSync, and the temporary directory + // is a symlink on macOS, so an unresolved root would never prefix it. + const repo = fs.realpathSync.native(makeTempDir("aidd-stated-workspace-")); + spawnSync("git", ["init", "-q", repo], { encoding: "utf8", env: CLEAN_ENV }); + writeTelemetryConfig(repo); + + const taskFolder = path.join(repo, "aidd_docs", "tasks", "2026_08", "2026_08_01_workspace-task"); + fs.mkdirSync(taskFolder, { recursive: true }); + const written = path.join(taskFolder, "plan.md"); + fs.writeFileSync(written, "x"); + + const vendorId = "workspace-host-session"; + const runFile = withWorkspaceHost(({ fileWrites, record }) => { + const runsDir = path.join(repo, "aidd_docs", "runs"); + fs.mkdirSync(runsDir, { recursive: true }); + const runId = record.generateUlid(); + const filePath = path.join(runsDir, record.runFileName(runId, vendorId)); + record.appendLine( + filePath, + record.buildSessionStartLine({ + at: "2026-08-01T00:00:00Z", + runId, + projectId: "acme/repo", + projectRemote: null, + host: WORKSPACE_HOST, + vendorId, + }), + ); + + // The hook's own working directory is outside the repository, as a machine-wide hook + // declaration leaves it. + fileWrites.handleFileWritten( + { workspacePaths: [repo], toolCall: { target: written } }, + WORKSPACE_HOST, + vendorId, + ); + return filePath; + }); + + const lines = fs + .readFileSync(runFile, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const stated = lines.filter((line) => line.type === "file_written"); + + assert.equal(stated.length, 1, "the path the host stated was dropped"); + assert.equal(stated[0].source, "tool-stated", "and it must say the host stated it, not that a walk found it"); + assert.equal(stated[0].path, "aidd_docs/tasks/2026_08/2026_08_01_workspace-task/plan.md"); + fs.rmSync(repo, { recursive: true, force: true }); +}); diff --git a/scripts/__tests__/architecture-check-cli.test.js b/scripts/__tests__/architecture-check-cli.test.js new file mode 100644 index 000000000..5505d948a --- /dev/null +++ b/scripts/__tests__/architecture-check-cli.test.js @@ -0,0 +1,107 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { spawnSync } = require("node:child_process"); + +const { governedPaths, scan } = require("../lib/architecture-scan.js"); + +const REPO_ROOT = path.resolve(__dirname, "../.."); +const CHECK = path.join(REPO_ROOT, "scripts/check-architecture-rules.js"); + +/** + * The commit-time half of issue #250. The write-time hook only ever sees Claude Code; this + * check is what every other tool, and every person, runs into. Its tests are therefore the + * ones that matter for coverage, not the hook's. + */ + +const CLEAN_SKILL = [ + "# Clean skill", + "", + "## Actions", + "", + "| # | Action | Role |", + "| --- | --- | --- |", + "| 01 | `step` | Do the one thing |", + "", +].join("\n"); + +const SIBLING_ADDRESS_SKILL = CLEAN_SKILL.replace( + "# Clean skill", + "# Clean skill\n\nSee @aidd-fixture-b:02-thing for the other half." +); + +const UNCITED_ACTION_SKILL = CLEAN_SKILL.replace("| 01 | `step` | Do the one thing |", ""); + +/** A temp tree with a `plugins/` root, so the file classifier applies exactly as it does here. */ +function makeTree(skillContent, actionNames = ["01-step.md"]) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "architecture-check-")); + const skillDir = path.join(root, "plugins/aidd-fixture-a/skills/01-demo"); + fs.mkdirSync(path.join(skillDir, "actions"), { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), skillContent); + for (const name of actionNames) { + fs.writeFileSync(path.join(skillDir, "actions", name), "# action\n"); + } + return { root, skill: "plugins/aidd-fixture-a/skills/01-demo/SKILL.md" }; +} + +test("a skill citing every action it provides yields nothing", () => { + const { root, skill } = makeTree(CLEAN_SKILL); + assert.deepEqual(scan(root, [skill]), []); +}); + +test("a skill addressing a sibling plugin is caught, and names the sibling", () => { + const { root, skill } = makeTree(SIBLING_ADDRESS_SKILL); + const found = scan(root, [skill]); + assert.equal(found.length, 1); + assert.equal(found[0].rule, "orthogonality"); + assert.equal(found[0].plugin, "aidd-fixture-b"); +}); + +test("an action file the router never cites is caught, and names the file", () => { + const { root, skill } = makeTree(UNCITED_ACTION_SKILL); + const found = scan(root, [skill]); + assert.equal(found.length, 1); + assert.equal(found[0].rule, "router-coherence"); + assert.match(found[0].message, /01-step\.md/); +}); + +test("a path outside the governed surface contributes nothing", () => { + const { root } = makeTree(SIBLING_ADDRESS_SKILL); + fs.writeFileSync(path.join(root, "README.md"), "See @aidd-fixture-b:02-thing.\n"); + assert.deepEqual(scan(root, ["README.md"]), []); +}); + +test("a path that does not exist is skipped rather than thrown over", () => { + const { root } = makeTree(CLEAN_SKILL); + assert.deepEqual(scan(root, ["plugins/aidd-fixture-a/skills/99-gone/SKILL.md"]), []); +}); + +test("the whole governed tree of this repository is silent, and is not empty", () => { + // A scan that matched nothing would pass every assertion above. Pin both halves. + const paths = governedPaths(REPO_ROOT); + assert.ok(paths.length > 100, `expected the tree to govern more than 100 files, got ${paths.length}`); + assert.deepEqual(scan(REPO_ROOT, paths), []); +}); + +test("the command exits 0 on this repository and says how much it checked", () => { + const result = spawnSync(process.execPath, [CHECK], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /governed file\(s\) checked, no violation/); +}); + +test("the command exits 1 and prints the fix when a governed file breaks a rule", () => { + const { root, skill } = makeTree(SIBLING_ADDRESS_SKILL); + const result = spawnSync(process.execPath, [CHECK, "--root", root, skill], { encoding: "utf8" }); + assert.equal(result.status, 1); + assert.match(result.stderr, /addresses sibling plugin "aidd-fixture-b"/); + assert.match(result.stderr, /Fix: name the concept aidd-fixture-b owns/); +}); + +test("the command scans a whole tree it is pointed at, not only this repository", () => { + const { root } = makeTree(UNCITED_ACTION_SKILL); + const result = spawnSync(process.execPath, [CHECK, "--root", root], { encoding: "utf8" }); + assert.equal(result.status, 1); + assert.match(result.stderr, /never names action file "01-step\.md"/); +}); diff --git a/scripts/__tests__/architecture-doc-matches-the-tree.test.js b/scripts/__tests__/architecture-doc-matches-the-tree.test.js new file mode 100644 index 000000000..482b7564f --- /dev/null +++ b/scripts/__tests__/architecture-doc-matches-the-tree.test.js @@ -0,0 +1,178 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const { checkOrthogonality } = require("../lib/architecture-rules.js"); + +const ROOT = path.resolve(__dirname, "../.."); +const DOC = path.join(ROOT, "docs/ARCHITECTURE.md"); + +/** + * `docs/ARCHITECTURE.md` describes the tree in two of its tables: the bundled hooks each plugin + * declares, and the plugin roster. Both were right when written and nothing compared them to the + * tree afterwards, which is how a document goes quietly wrong. These tests are that comparison. + * + * Tables are found by their header cells and read by column name, never by position, so adding a + * column, reordering two tables under one heading or leaving a blank line between rows changes + * nothing here. What must not change is a header cell this file names. + */ + +const CELL_SEPARATOR = /(? cell.trim()); +} + +function isSeparatorRow(cells) { + return cells.every((cell) => /^:?-+:?$/.test(cell)); +} + +/** + * Every table in the file, as `{ header, rows }`. A blank line does not end a table, because a + * table split for grouping is still one table. Any other non-row line does, because otherwise two + * tables separated by prose read as one. + */ +function tables() { + const found = []; + let current = null; + + for (const line of fs.readFileSync(DOC, "utf8").split("\n")) { + if (!line.trim().startsWith("|")) { + if (line.trim() !== "") current = null; + continue; + } + const cells = rowCells(line); + if (isSeparatorRow(cells)) continue; + if (current === null) { + current = { header: cells, rows: [] }; + found.push(current); + } else { + current.rows.push(cells); + } + } + return found; +} + +/** The one table whose header holds every name given. Never two, never none. */ +function tableWithColumns(...names) { + const matches = tables().filter((table) => + names.every((name) => table.header.some((cell) => cell.toLowerCase() === name.toLowerCase())) + ); + assert.equal( + matches.length, + 1, + `expected exactly one table in ARCHITECTURE.md with columns ${names.join(", ")}, found ${matches.length}` + ); + return matches[0]; +} + +function column(table, name) { + const at = table.header.findIndex((cell) => cell.toLowerCase() === name.toLowerCase()); + return (cells) => cells[at] ?? ""; +} + +/** `` `aidd-ui` 🚧 `` and `` `SessionStart` · `Stop` `` both mean the backticked tokens. */ +function backticked(cell) { + return [...cell.matchAll(/`([^`]+)`/g)].map((match) => match[1]); +} + +function onlyBacktickedName(cell, where) { + const names = backticked(cell); + assert.equal(names.length, 1, `${where}: expected one backticked name, got ${JSON.stringify(cell)}`); + return names[0]; +} + +function pluginDirectories() { + return fs + .readdirSync(path.join(ROOT, "plugins"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +function declaredHookEvents(plugin) { + const manifest = path.join(ROOT, "plugins", plugin, "hooks/hooks.json"); + if (!fs.existsSync(manifest)) return null; + return Object.keys(JSON.parse(fs.readFileSync(manifest, "utf8")).hooks ?? {}).sort(); +} + +const hooksTable = () => tableWithColumns("Plugin", "Event", "Runs"); +const concernsTable = () => tableWithColumns("Plugin", "Concern", "Layer"); + +test("the bundled hooks table names every plugin that declares hooks, and no other", () => { + const table = hooksTable(); + const plugin = column(table, "Plugin"); + const documented = table.rows.map((cells) => onlyBacktickedName(plugin(cells), "hooks table")).sort(); + assert.deepEqual(documented, pluginDirectories().filter((name) => declaredHookEvents(name) !== null)); +}); + +test("the bundled hooks table names the events each plugin actually declares", () => { + const table = hooksTable(); + const plugin = column(table, "Plugin"); + const event = column(table, "Event"); + for (const cells of table.rows) { + const name = onlyBacktickedName(plugin(cells), "hooks table"); + assert.deepEqual( + backticked(event(cells)).sort(), + declaredHookEvents(name), + `${name}: the table and plugins/${name}/hooks/hooks.json disagree` + ); + } +}); + +test("the bundled hooks table names a script that exists", () => { + // Without this the table can point at a deleted hook and still read as current. + const table = hooksTable(); + const plugin = column(table, "Plugin"); + const runs = column(table, "Runs"); + for (const cells of table.rows) { + const name = onlyBacktickedName(plugin(cells), "hooks table"); + const script = onlyBacktickedName(runs(cells), `hooks table, ${name} Runs cell`); + const full = path.join(ROOT, "plugins", name, script); + assert.ok(fs.existsSync(full), `plugins/${name}/${script} is named in ARCHITECTURE.md and does not exist`); + } +}); + +test("the plugin concerns table has one row per plugin in the tree", () => { + const table = concernsTable(); + const plugin = column(table, "Plugin"); + const documented = table.rows.map((cells) => onlyBacktickedName(plugin(cells), "concerns table")).sort(); + assert.deepEqual(documented, pluginDirectories()); +}); + +/** + * Rule one has two holes, both deliberate, both in `isExemptFromOrthogonality`. The 00-onboard one + * is load-bearing only while that skill still hardcodes the addresses it hands a person to type. + * Once it resolves its providers at runtime the hole protects nothing and should go, and nothing + * else would say so. + */ +test("the 00-onboard exemption still protects something", () => { + const skillDir = path.join(ROOT, "plugins/aidd-context/skills/00-onboard"); + const files = []; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith(".md")) files.push(full); + } + }; + walk(skillDir); + + // Judged under a path the exemption does not cover, so the rule speaks instead of skipping. + const probePath = "plugins/aidd-context/skills/99-exemption-probe/SKILL.md"; + const wouldViolate = files.filter( + (file) => checkOrthogonality(probePath, fs.readFileSync(file, "utf8")).length > 0 + ); + + assert.ok( + wouldViolate.length > 0, + "00-onboard no longer hardcodes a sibling address. Delete ONBOARD_EXEMPT_PREFIX from " + + "scripts/lib/architecture-rules.js, its exemption bullet in docs/ARCHITECTURE.md, and this test." + ); +}); diff --git a/scripts/__tests__/architecture-rules.property.test.js b/scripts/__tests__/architecture-rules.property.test.js new file mode 100644 index 000000000..ec8434428 --- /dev/null +++ b/scripts/__tests__/architecture-rules.property.test.js @@ -0,0 +1,248 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const { checkRouterCoherence } = require("../lib/architecture-rules.js"); + +const ROOT = path.resolve(__dirname, "../.."); +const TICK = "`"; + +/** + * Rule two decided by one property, over every shape a router is written in, instead of one + * test per shape someone happened to think of. + * + * The property: a section that cites every action file it provides is silent, and removing any + * one of those citations yields exactly one violation — the one naming that file. + * + * The shapes are generated rather than random. A guard that fails on a seed nobody can reproduce + * is worse than no guard, and the repository's own root holds six dev dependencies, none of them + * a generator library. The matrix below is small enough to enumerate and wide enough to cover + * what six rounds of review found by hand. + */ + +const HEADERS = { + bare: ["| Action | Does |"], + numbered: ["| # | Action | Role |"], + plural: ["| # | Actions | Role |"], +}; + +const SEPARATORS = { + three: "---", + two: "--", + aligned: ":---", +}; + +/** How a row names the action it routes to. Each is a citation shape the engine accepts. */ +const CITATIONS = { + stem: (name) => stemOf(name), + backtickedStem: (name) => `${TICK}${stemOf(name)}${TICK}`, + numberedName: (name) => name.replace(/\.md$/, ""), + backtickedFile: (name) => `${TICK}${name}${TICK}`, +}; + +/** Text placed around the router that must not change any verdict. */ +const NOISE = { + none: () => [], + prose: (names) => [ + "", + `Run them in order. The ${stemOf(names[0])} step is the culmination, and ${stemOf( + names[names.length - 1] + )} closes it.`, + ], + glossaryTable: () => ["", "| Term | Synonym |", "| --- | --- |", "| step | move |"], + fencedExample: (names) => [ + "", + "An example of the shape a router takes:", + "", + "```md", + "| # | Action | Role |", + "| --- | --- | --- |", + `| 99 | ${stemOf(names[names.length - 1])} | an example, not a router |`, + "```", + ], + /** The same example, citing a file name rather than a stem: the shape that reads through a + * fence for the path form and must not for this one. */ + fencedFilename: (names) => [ + "", + "```md", + "| # | Action | Role |", + "| --- | --- | --- |", + `| 99 | ${TICK}${names[names.length - 1]}${TICK} | an example, not a router |`, + "```", + ], + trailingParagraph: () => ["", "Before running an action, read its file in `actions/`."], +}; + +function stemOf(name) { + return name.replace(/\.md$/, "").replace(/^\d+-/, ""); +} + +/** One `SKILL.md` body, built from the matrix. `omit` is the action file left uncited. */ +function buildSkill({ header, separator, citation, noise, names, omit, splitAfter }) { + const headerRow = HEADERS[header][0]; + const columns = headerRow.split("|").filter((cell) => cell.trim() !== "").length; + const separatorRow = `| ${Array.from({ length: columns }, () => SEPARATORS[separator]).join(" | ")} |`; + + const rows = []; + names.forEach((name, index) => { + if (name === omit) return; + if (splitAfter !== undefined && index === splitAfter) rows.push(""); + const cited = CITATIONS[citation](name); + rows.push(columns === 2 ? `| ${cited} | does it |` : `| 0${index + 1} | ${cited} | does it |`); + }); + + return [ + "# Generated skill", + "", + "## Actions", + "", + headerRow, + separatorRow, + ...rows, + ...NOISE[noise](names), + "", + "## Transversal rules", + "", + "- Nothing.", + ].join("\n"); +} + +const NAMES = ["01-first.md", "02-second.md", "03-third.md"]; +const FILE = "plugins/aidd-fixture-a/skills/01-generated/SKILL.md"; + +function shapes() { + const out = []; + for (const header of Object.keys(HEADERS)) { + for (const separator of Object.keys(SEPARATORS)) { + for (const citation of Object.keys(CITATIONS)) { + for (const noise of Object.keys(NOISE)) { + for (const splitAfter of [undefined, 1]) { + out.push({ header, separator, citation, noise, splitAfter }); + } + } + } + } + } + return out; +} + +function describe(shape, omit) { + const split = shape.splitAfter === undefined ? "unsplit" : "split by a blank line"; + return `${shape.header} header, ${shape.separator} separator, ${shape.citation} citation, ${shape.noise} noise, ${split}${ + omit ? `, omitting ${omit}` : "" + }`; +} + +test("every shape that cites all its actions is silent", () => { + const failures = []; + for (const shape of shapes()) { + const content = buildSkill({ ...shape, names: NAMES, omit: null }); + const violations = checkRouterCoherence(FILE, content, NAMES); + if (violations.length !== 0) { + failures.push(`${describe(shape)} => ${violations.map((v) => v.message).join(" | ")}`); + } + } + assert.deepEqual(failures, [], `a router shape was refused although it cites every action:\n${failures.join("\n")}`); +}); + +test("every shape that drops one citation yields exactly that one violation", () => { + const failures = []; + for (const shape of shapes()) { + for (const omit of NAMES) { + const content = buildSkill({ ...shape, names: NAMES, omit }); + const violations = checkRouterCoherence(FILE, content, NAMES); + if (violations.length !== 1) { + failures.push(`${describe(shape, omit)} => ${violations.length} violations`); + continue; + } + if (!violations[0].message.includes(omit)) { + failures.push(`${describe(shape, omit)} => named ${violations[0].message}`); + } + } + } + assert.deepEqual(failures, [], `a dropped citation was missed or misattributed:\n${failures.join("\n")}`); +}); + +test("the shape matrix is wide enough to be worth running", () => { + // A generator that silently produces nothing passes every property above. Pin the count so a + // matrix that collapses fails here rather than going quietly green. + // 3 headers x 3 separators x 4 citation shapes x 6 noises x 2 split positions. + assert.equal(shapes().length, 432); +}); + +/** + * The same property against the repository's own routers, which is where it has to hold: every + * action the tree provides is cited, and removing the line that cites it is caught. + */ +function realSkills() { + const skills = []; + const pluginsDir = path.join(ROOT, "plugins"); + for (const owner of fs.readdirSync(pluginsDir)) { + const skillsDir = path.join(pluginsDir, owner, "skills"); + if (!fs.existsSync(skillsDir)) continue; + for (const skill of fs.readdirSync(skillsDir)) { + const skillMd = path.join(skillsDir, skill, "SKILL.md"); + const actionsDir = path.join(skillsDir, skill, "actions"); + if (!fs.existsSync(skillMd) || !fs.existsSync(actionsDir)) continue; + const names = fs.readdirSync(actionsDir).filter((name) => name.endsWith(".md")); + if (names.length === 0) continue; + skills.push({ + relPath: `plugins/${owner}/skills/${skill}/SKILL.md`, + content: fs.readFileSync(skillMd, "utf8"), + names, + }); + } + } + return skills; +} + +/** The skill's content with every table row citing `name` removed. */ +function withoutCitationOf(content, name) { + const stem = stemOf(name).toLowerCase(); + const noExt = name.replace(/\.md$/, "").toLowerCase(); + return content + .split("\n") + .filter((line) => { + if (!/^\s*\|/.test(line)) return true; + const cells = line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim().replace(/^`|`$/g, "").toLowerCase()); + return !cells.includes(stem) && !cells.includes(noExt) && !cells.includes(name.toLowerCase()); + }) + .join("\n"); +} + +test("every router in the tree cites every action it provides", () => { + const failures = []; + for (const { relPath, content, names } of realSkills()) { + const violations = checkRouterCoherence(relPath, content, names); + if (violations.length > 0) failures.push(`${relPath} => ${violations.map((v) => v.message).join(" | ")}`); + } + assert.deepEqual(failures, [], failures.join("\n")); +}); + +test("removing a real router's citation is caught, for every action in the tree", () => { + // `aidd-dev:10-todo` names its one action as a fenced path rather than a table row, so there + // is no row to remove and nothing to catch. It is the one documented exception. + const FENCED_PATH_SKILL = "plugins/aidd-dev/skills/10-todo/SKILL.md"; + const missed = []; + let checked = 0; + + for (const { relPath, content, names } of realSkills()) { + if (relPath === FENCED_PATH_SKILL) continue; + for (const name of names) { + checked += 1; + const violations = checkRouterCoherence(relPath, withoutCitationOf(content, name), names); + if (!violations.some((violation) => violation.message.includes(name))) { + missed.push(`${relPath} ${name}`); + } + } + } + + assert.deepEqual(missed, [], `a deleted citation went unnoticed:\n${missed.join("\n")}`); + assert.ok(checked > 100, `expected the tree to offer more than 100 citations to remove, got ${checked}`); +}); diff --git a/scripts/__tests__/architecture-rules.test.js b/scripts/__tests__/architecture-rules.test.js new file mode 100644 index 000000000..9eb7df1b8 --- /dev/null +++ b/scripts/__tests__/architecture-rules.test.js @@ -0,0 +1,522 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const { checkArchitecture, classifyFile } = require("../lib/architecture-rules.js"); + +const ROOT = path.resolve(__dirname, "../.."); +const FIXTURES = path.join(__dirname, "fixtures/architecture-rules"); + +function fixture(relPath) { + return fs.readFileSync(path.join(FIXTURES, relPath), "utf8"); +} + +/** + * Thin tree walker for the sweep test only. The engine itself never reads the filesystem — + * this is the caller that supplies its inputs from the real `plugins/` tree. + */ +function sweepPlugins() { + const files = []; + const pluginsDir = path.join(ROOT, "plugins"); + + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + files.push(full); + } + } + } + walk(pluginsDir); + + const violations = []; + let skillsWithActions = 0; + for (const absPath of files) { + const relPath = path.relative(ROOT, absPath).split(path.sep).join("/"); + // The engine's own classifier decides what is governed — including `assets/` — so the + // walker never keeps a second copy of that rule that could drift from it. + if (!classifyFile(relPath)) continue; + const content = fs.readFileSync(absPath, "utf8"); + let actionFileNames; + if (path.basename(absPath) === "SKILL.md") { + const actionsDir = path.join(path.dirname(absPath), "actions"); + actionFileNames = fs.existsSync(actionsDir) + ? fs.readdirSync(actionsDir).filter((f) => f.endsWith(".md")) + : []; + if (actionFileNames.length > 0) skillsWithActions += 1; + } + violations.push(...checkArchitecture(relPath, content, actionFileNames)); + } + return { violations, skillsWithActions }; +} + +test("a clean skill with no sibling address and a fully-named action yields no violations", () => { + const content = fixture("clean-skill/SKILL.md"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-clean/SKILL.md", + content, + ["01-step.md"] + ); + assert.deepEqual(violations, []); +}); + +test("a skill addressing a sibling plugin yields a violation naming file, line and owning plugin", () => { + const content = fixture("cross-address-skill/SKILL.md"); + const filePath = "plugins/aidd-fixture-a/skills/02-cross-address/SKILL.md"; + const violations = checkArchitecture(filePath, content, ["01-step.md"]); + + assert.equal(violations.length, 1); + const [violation] = violations; + assert.equal(violation.file, filePath); + assert.equal(violation.plugin, "aidd-fixture-b"); + const lines = content.split("\n"); + assert.equal(lines[violation.line - 1].includes("/aidd-fixture-b:01-noop"), true); +}); + +test("an agent's Skills you may invoke list addressing siblings yields no violations", () => { + const content = fixture("agent-permission-list.md"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/agents/reviewer.md", + content + ); + assert.deepEqual(violations, []); +}); + +test("an orchestrator plugin is exempt from orthogonality alone", () => { + const content = fixture("orchestrator-skill/SKILL.md"); + // No actionFileNames: isolates rule one, since rule two short-circuits on an empty list. + const violations = checkArchitecture( + "plugins/aidd-orchestrator/skills/99-fixture/SKILL.md", + content + ); + assert.deepEqual(violations, []); +}); + +test("router coherence still applies to an orchestrator plugin", () => { + const content = fixture("orchestrator-incoherent-skill/SKILL.md"); + const filePath = "plugins/aidd-orchestrator/skills/99-fixture/SKILL.md"; + const violations = checkArchitecture(filePath, content, ["01-route.md"]); + + assert.equal(violations.length, 1); + const [violation] = violations; + assert.equal(violation.rule, "router-coherence"); + assert.equal(violation.plugin, "aidd-orchestrator"); +}); + +test("a file under assets/ yields no violations regardless of content", () => { + const content = fixture("assets-note.md"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-clean/assets/note.md", + content + ); + assert.deepEqual(violations, []); +}); + +test("an action file nested a level deeper than usual is still governed", () => { + const content = fixture("nested-action.md"); + const filePath = "plugins/aidd-fixture-a/skills/01-clean/actions/group/nested-action.md"; + const violations = checkArchitecture(filePath, content); + + assert.equal(violations.length, 1); + const [violation] = violations; + assert.equal(violation.plugin, "aidd-fixture-b"); +}); + +test("a SKILL.md nested a level deeper than usual is still governed", () => { + const content = fixture("nested-skill/SKILL.md"); + const filePath = "plugins/aidd-fixture-a/skills/01-clean/variant/SKILL.md"; + const violations = checkArchitecture(filePath, content); + + assert.equal(violations.length, 1); + const [violation] = violations; + assert.equal(violation.plugin, "aidd-fixture-b"); +}); + +test("a reference file nested two levels under references/ is still governed", () => { + const content = fixture("nested-reference.md"); + const filePath = "plugins/aidd-fixture-a/skills/01-clean/references/state/nested-reference.md"; + const violations = checkArchitecture(filePath, content); + + assert.equal(violations.length, 1); + const [violation] = violations; + assert.equal(violation.plugin, "aidd-fixture-b"); +}); + +test("a SKILL.md nested under assets/ is ungoverned for that reason alone", () => { + const content = fixture("nested-assets-skill/SKILL.md"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-clean/assets/nested/SKILL.md", + content + ); + assert.deepEqual(violations, []); +}); + +test("an action file the Actions section never names yields one violation", () => { + const content = fixture("unnamed-action-skill/SKILL.md"); + const filePath = "plugins/aidd-fixture-a/skills/03-unnamed-action/SKILL.md"; + const violations = checkArchitecture(filePath, content, [ + "01-step-one.md", + "02-step-two.md", + ]); + + assert.equal(violations.length, 1); + const [violation] = violations; + assert.equal(violation.file, filePath); + assert.equal(violation.plugin, "aidd-fixture-a"); + const lines = content.split("\n"); + assert.equal(lines[violation.line - 1].trim(), "## Actions"); +}); + +test("00-onboard's own reference menus are exempt from orthogonality", () => { + const content = fixture("onboard-menu.md"); + const filePath = "plugins/aidd-context/skills/00-onboard/references/order/onboard-menu.md"; + const violations = checkArchitecture(filePath, content); + assert.deepEqual(violations, []); +}); + +test("a bare plugin:skill address is caught, but not one embedded in a longer identifier", () => { + const content = fixture("bare-address-skill/SKILL.md"); + const filePath = "plugins/aidd-fixture-a/skills/07-bare-address/SKILL.md"; + const violations = checkArchitecture(filePath, content, ["01-step.md"]); + + assert.equal(violations.length, 1); + const [violation] = violations; + assert.equal(violation.plugin, "aidd-fixture-b"); + assert.match(violation.message, /"aidd-fixture-b:01-noop"/); +}); + +test("a stem that is a substring of a sibling action's stem is not mistaken for a mention", () => { + const content = fixture("stem-substring-skill/SKILL.md"); + const filePath = "plugins/aidd-fixture-a/skills/05-stem-substring/SKILL.md"; + const violations = checkArchitecture(filePath, content, [ + "01-assert.md", + "02-assert-architecture.md", + ]); + + assert.equal(violations.length, 1); + const [violation] = violations; + assert.match(violation.message, /"01-assert\.md"/); +}); + +test("a deleted table row is still caught when unrelated prose loosely contains its word", () => { + // Regression for the substring-over-prose bug: "Run them in order, `01 → 04`. The plan is + // the culmination." contains the word "plan" in ordinary prose, not as a table cell, an + // actions/ path, or a backticked .md filename — so it must not count as naming 04-plan.md. + const content = fixture("deleted-row-masked-by-prose-skill/SKILL.md"); + const filePath = "plugins/aidd-fixture-a/skills/09-deleted-row/SKILL.md"; + const violations = checkArchitecture(filePath, content, ["01-frame.md", "04-plan.md"]); + + assert.equal(violations.length, 1); + const [violation] = violations; + assert.match(violation.message, /"04-plan\.md"/); +}); + +test("a skill whose Actions section and action files agree yields no violations", () => { + const content = fixture("clean-skill/SKILL.md"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-clean/SKILL.md", + content, + ["01-step.md"] + ); + assert.deepEqual(violations, []); +}); + +test("sweeping the repository's own plugins/ tree yields zero violations", () => { + const { violations, skillsWithActions } = sweepPlugins(); + assert.deepEqual(violations, []); + // A sweep that never actually exercised a skill with action files would pass the same way — + // this pins the sweep to the measured count so it cannot go vacuously green. + assert.equal(skillsWithActions, 48); +}); + +test("a table that declares no action column cites nothing, whatever its cells read", () => { + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-glossary/SKILL.md", + fixture("glossary-column-skill/SKILL.md"), + ["01-step.md"] + ); + assert.equal(violations.length, 1); + assert.equal(violations[0].rule, "router-coherence"); + assert.match(violations[0].message, /never names action file "01-step\.md"/); +}); + +test("a name in a column other than the action column does not cite that action", () => { + const content = [ + "# Two column skill", + "", + "## Actions", + "", + "| # | Action | Next |", + "| --- | --- | --- |", + "| 01 | `first` | second |", + "", + "## Transversal rules", + "", + "- Nothing.", + ].join("\n"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-two-column/SKILL.md", + content, + ["01-first.md", "02-second.md"] + ); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /never names action file "02-second\.md"/); +}); + +test("a skill holding action files with no Actions section at all is refused", () => { + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-no-section/SKILL.md", + fixture("no-actions-section-skill/SKILL.md"), + ["01-step.md"] + ); + assert.equal(violations.length, 1); + assert.equal(violations[0].rule, "router-coherence"); + assert.match(violations[0].message, /no "## Actions" section/); +}); + +test("a glossary table before the router does not blind the router's own column", () => { + const content = [ + "# Two table skill", + "", + "## Actions", + "", + "| Term | Synonym |", + "| --- | --- |", + "| step | move |", + "", + "| # | Action | Role |", + "| --- | --- | --- |", + "| 01 | `first` | Do it |", + "", + "## Transversal rules", + "", + "- Nothing.", + ].join("\n"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-two-table/SKILL.md", + content, + ["01-first.md"] + ); + assert.deepEqual(violations, []); +}); + +test("a blank line splitting the router table does not unname the rows below it", () => { + const content = [ + "# Split table skill", + "", + "## Actions", + "", + "| # | Action | Role |", + "| --- | --- | --- |", + "| 01 | `first` | Do it |", + "", + "| 02 | `second` | Then this |", + "", + "## Transversal rules", + "", + "- Nothing.", + ].join("\n"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-split/SKILL.md", + content, + ["01-first.md", "02-second.md"] + ); + assert.deepEqual(violations, []); +}); + +test("a two-dash separator row still marks the header it follows", () => { + const content = [ + "# Short rule skill", + "", + "## Actions", + "", + "| # | Action | Does |", + "| -- | --- | --- |", + "| 01 | first | Do it |", + "", + "## Transversal rules", + "", + "- Nothing.", + ].join("\n"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-short-rule/SKILL.md", + content, + ["01-first.md"] + ); + assert.deepEqual(violations, []); +}); + +test("a plural Actions header declares the action column too", () => { + const content = [ + "# Plural header skill", + "", + "## Actions", + "", + "| # | Actions | Role |", + "| --- | --- | --- |", + "| 01 | `first` | Do it |", + "", + "## Transversal rules", + "", + "- Nothing.", + ].join("\n"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-plural/SKILL.md", + content, + ["01-first.md"] + ); + assert.deepEqual(violations, []); +}); + +test("two action files sharing a stem are not both covered by one citation", () => { + const content = [ + "# Shared stem skill", + "", + "## Actions", + "", + "| # | Action | Role |", + "| --- | --- | --- |", + "| 01 | `plan` | Do it |", + "", + "## Transversal rules", + "", + "- Nothing.", + ].join("\n"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-shared-stem/SKILL.md", + content, + ["01-plan.md", "04-plan.md"] + ); + assert.equal(violations.length, 2); + assert.match(violations[0].message, /never names action file "01-plan\.md"/); + assert.match(violations[1].message, /never names action file "04-plan\.md"/); +}); + +test("a fenced example table inside the Actions section cites nothing", () => { + const content = [ + "# Fenced example skill", + "", + "## Actions", + "", + "| # | Action | Role |", + "| --- | --- | --- |", + "| 01 | `first` | Do it |", + "", + "An example of the shape a router takes:", + "", + "```md", + "| 02 | second | Then this |", + "```", + "", + "## Transversal rules", + "", + "- Nothing.", + ].join("\n"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-fenced/SKILL.md", + content, + ["01-first.md", "02-second.md"] + ); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /never names action file "02-second\.md"/); +}); + +test("a fenced actions/.md path still cites, the way 10-todo does", () => { + const content = [ + "# Fenced path skill", + "", + "## Actions", + "", + "```md", + "actions/01-only.md", + "```", + "", + "## Transversal rules", + "", + "- Nothing.", + ].join("\n"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-fenced-path/SKILL.md", + content, + ["01-only.md"] + ); + assert.deepEqual(violations, []); +}); + +test("a fenced ## heading inside the Actions section does not end it early", () => { + const content = [ + "# Fenced heading skill", + "", + "## Actions", + "", + "```md", + "## Actions", + "```", + "", + "| # | Action | Role |", + "| --- | --- | --- |", + "| 01 | `first` | Do it |", + "", + "## Transversal rules", + "", + "- Nothing.", + ].join("\n"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-fenced-heading/SKILL.md", + content, + ["01-first.md"] + ); + assert.deepEqual(violations, []); +}); + +test("a backticked file name inside a fenced example does not cite", () => { + const fence = "```"; + const content = [ + "# Fenced filename skill", + "", + "## Actions", + "", + `${fence}md`, + "| Action | When |", + "| --- | --- |", + `| ${"`"}02-gone.md${"`"} | b |`, + fence, + "", + "| Action | When |", + "| --- | --- |", + "| 01-keep | a |", + "", + "## Transversal rules", + "", + "- Nothing.", + ].join("\n"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-fenced-name/SKILL.md", + content, + ["01-keep.md", "02-gone.md"] + ); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /never names action file "02-gone\.md"/); +}); + +test("a fence nobody closed is not read as a fence at all", () => { + const content = [ + "# Unterminated fence skill", + "", + "## Actions", + "", + "```md", + "| # | Action | Role |", + "| --- | --- | --- |", + "| 01 | `first` | Do it |", + ].join("\n"); + const violations = checkArchitecture( + "plugins/aidd-fixture-a/skills/01-unterminated/SKILL.md", + content, + ["01-first.md"] + ); + assert.deepEqual(violations, []); +}); diff --git a/scripts/__tests__/check-written-file-hook.test.js b/scripts/__tests__/check-written-file-hook.test.js new file mode 100644 index 000000000..16b8a9242 --- /dev/null +++ b/scripts/__tests__/check-written-file-hook.test.js @@ -0,0 +1,55 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const root = path.resolve(__dirname, "../.."); +const hook = path.join(root, ".claude/hooks/check-written-file.js"); +const biome = path.join(root, "cli/node_modules/.bin", process.platform === "win32" ? "biome.cmd" : "biome"); +const noBiome = !fs.existsSync(biome); + +function runHook(payload) { + return spawnSync(process.execPath, [hook], { input: payload, encoding: "utf8" }); +} + +function probe(name, content) { + const file = path.join(root, "cli/src", `.hook-probe-${process.pid}-${name}.ts`); + fs.writeFileSync(file, content); + return file; +} + +test("hands a broken Biome rule back to the agent, naming it", { skip: noBiome }, () => { + const file = probe("broken", "export function one(x: number) {\n return x == 1;\n}\n"); + try { + const result = runHook(JSON.stringify({ tool_input: { file_path: file } })); + assert.equal(result.status, 2); + assert.match(result.stderr, /noDoubleEquals/); + } finally { + fs.rmSync(file, { force: true }); + } +}); + +test("formats a file in place and stays silent on what would not block a commit", { skip: noBiome }, () => { + const file = probe("clean", "export function echo(value: any) {\n return value;\n}\n"); + try { + const result = runHook(JSON.stringify({ tool_input: { file_path: file } })); + assert.equal(result.status, 0); + assert.equal(result.stdout + result.stderr, ""); + assert.equal(fs.readFileSync(file, "utf8"), "export function echo(value: any) {\n return value;\n}\n"); + } finally { + fs.rmSync(file, { force: true }); + } +}); + +test("leaves a file outside cli/ alone", () => { + const result = runHook(JSON.stringify({ tool_input: { file_path: path.join(root, "README.md") } })); + assert.equal(result.status, 0); + assert.equal(result.stdout + result.stderr, ""); +}); + +test("answers an unreadable payload with silence, never a failure", () => { + const result = runHook("not json"); + assert.equal(result.status, 0); + assert.equal(result.stdout + result.stderr, ""); +}); diff --git a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js index eac6ce08f..d67cee4ab 100644 --- a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js +++ b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js @@ -59,3 +59,83 @@ test("the changes filter names the workflow file itself as relevant", () => { `${ownPath} must be a case of the relevance filter, on its own line` ); }); + +test("only a proven next snapshot tree skips the promotion or main mutation matrix", () => { + const workflow = cliCiWorkflow(); + const changes = workflow.jobs.changes; + const promotion = changes.steps.find((step) => step.id === "promotion"); + const mutation = changes.steps.find((step) => step.id === "mutation"); + + assert.equal(workflow.permissions.actions, "read"); + assert.equal(workflow.permissions.contents, "read"); + assert.equal(workflow.permissions["pull-requests"], undefined); + assert.deepEqual(changes.permissions, { + actions: "read", + contents: "read", + "pull-requests": "read", + }); + assert.equal(changes.outputs.trusted_promotion, "${{ steps.promotion.outputs.trusted }}"); + assert.equal(promotion.name, "Check whether a promotion snapshot or main merge passed next"); + assert.match(promotion.run, /EVENT_NAME.*pull_request/); + assert.match(promotion.run, /BASE_REF.*main/); + assert.equal(promotion.env.HEAD_REPO, "${{ github.event.pull_request.head.repo.full_name }}"); + assert.match(promotion.run, /HEAD_REF.*\^promote\/next-to-main-\[0-9\]\+\$/); + assert.match(promotion.run, /HEAD_REPO" == "\$REPO/); + assert.match(promotion.run, /git merge-base --is-ancestor "\$BASE_SHA" "\$HEAD_SHA"/); + assert.match(promotion.run, /git rev-parse "\$CURRENT_SHA\^\{tree\}"/); + assert.match(promotion.run, /"\$merge_tree" != "\$snapshot_tree"/); + assert.match( + promotion.run, + /actions\/workflows\/cli-ci\.yml\/runs\?branch=next&event=push&status=completed&head_sha=\$HEAD_SHA/ + ); + assert.match(promotion.run, /\.conclusion == "success"/); + assert.match(promotion.run, /\.name == "cli \/ gate" and \.conclusion == "success"/); + assert.match(promotion.run, /reason="could not list successful cli CI push runs"/); + assert.match(promotion.run, /reason="could not inspect cli CI run \$run_id"/); + assert.match(promotion.run, /echo "promotion mutation reuse: \$reason"/); + + // Main can skip mutations only for the exact two-parent promotion merge whose tree is the + // snapshot already gated on next. Any git or associated-PR proof mismatch remains untrusted. + assert.match(promotion.run, /EVENT_NAME" == "push" && "\$GITHUB_REF" == "refs\/heads\/main"/); + assert.match(promotion.run, /git rev-list --parents -n 1 "\$CURRENT_SHA"/); + assert.match(promotion.run, /-z "\$snapshot_sha" \|\| -n "\$\{extra_parent:-\}"/); + assert.match(promotion.run, /git rev-parse "\$snapshot_sha\^\{tree\}"/); + assert.match(promotion.run, /"\$main_tree" != "\$snapshot_tree"/); + assert.match(promotion.run, /\/repos\/\$REPO\/commits\/\$CURRENT_SHA\/pulls/); + assert.match(promotion.run, /\.base\.ref == "main"/); + assert.match(promotion.run, /\.head\.ref \| test\("\^promote\/next-to-main-\[0-9\]\+\$"\)/); + assert.match(promotion.run, /\.merged_at != null/); + assert.match(promotion.run, /"\$base_repo" == "\$REPO"/); + assert.match(promotion.run, /"\$head_repo" == "\$REPO"/); + assert.match(promotion.run, /"\$pr_head_sha" == "\$snapshot_sha"/); + assert.match(promotion.run, /"\$merge_commit_sha" == "\$CURRENT_SHA"/); + assert.match(promotion.run, /"\$matching_prs" -ne 1/); + assert.match(promotion.run, /HEAD_SHA="\$snapshot_sha"/); + + assert.match(mutation.run, /steps\.promotion\.outputs\.trusted.*== "true"/); + assert.match(mutation.run, /scopes='\[\]'/); + assert.match(mutation.run, /else[\s\S]*mutation-scopes-to-run\.mjs/); + assert.equal(workflow.jobs["cli-mutation"].if, "needs.changes.outputs.mutation_scopes != '[]'"); + + // These checks validate the current PR merge ref or main commit; reuse never turns them off. + for (const name of [ + "cli-typecheck", + "cli-lint", + "cli-architecture", + "cli-coverage", + "cli-smoke", + "cli-build", + "cli-knip", + "identifier-join", + "cli-jscpd", + "kanban-checks", + "windows", + ]) { + assert.deepEqual(workflow.jobs[name].needs, ["changes"], `${name} must still depend on changes`); + assert.equal( + workflow.jobs[name].if, + "needs.changes.outputs.relevant == 'true'", + `${name} must still run for a relevant promotion PR` + ); + } +}); diff --git a/scripts/__tests__/credit-release-authors.test.js b/scripts/__tests__/credit-release-authors.test.js new file mode 100644 index 000000000..7b41d2b1b --- /dev/null +++ b/scripts/__tests__/credit-release-authors.test.js @@ -0,0 +1,398 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const test = require("node:test"); +const yaml = require("js-yaml"); + +const { credit, tagsFromOutputs, who, isMerge, prCredit, creditReleases } = require("../credit-release-authors.cjs"); + +const root = path.resolve(__dirname, "../.."); + +// Real v5.10.0 lines, read-only. +const LINE_A = + "* **cli:** a record names the skill its own prompt invoked ([#783](https://github.com/ai-driven-dev/framework/issues/783)) ([7fbe889](https://github.com/ai-driven-dev/framework/commit/7fbe8897cee6ed522d1f52a2124c4109eb101543))"; +const LINE_B = + "* **cli:** a repair that repaired nothing says so ([#762](https://github.com/ai-driven-dev/framework/issues/762)) ([dccbef2](https://github.com/ai-driven-dev/framework/commit/dccbef25b43943904ec9a3cee3b5c2056d2cfdd6))"; +const LINE_CLOSES = + "* **cli:** carry the catalog's recommended field under metadata, where Claude Code does not warn ([#803](https://github.com/ai-driven-dev/framework/issues/803)) ([4531b74](https://github.com/ai-driven-dev/framework/commit/4531b7466a03cd136764540e55f5c867dbdcafb5)), closes [#800](https://github.com/ai-driven-dev/framework/issues/800)"; +const LINE_DEPENDABOT = + "* **deps-dev:** bump js-yaml from 5.3.0 to 5.4.1 ([#734](https://github.com/ai-driven-dev/framework/issues/734)) ([07a2364](https://github.com/ai-driven-dev/framework/commit/07a2364e282bc0aee1750250ee5b7594a6a5fe3a))"; +const LINE_NO_SHA = "### Bug Fixes"; +const HEADER = "## [5.10.0](https://github.com/ai-driven-dev/framework/compare/v5.9.0...v5.10.0) (2026-09-09)"; + +// A non-merge resolver: matches the shape credit() has always expected for a plain commit. +const nonMerge = (who) => () => ({ who }); + +test("T1: two contributors, each credited", () => { + const body = [LINE_A, LINE_B].join("\n"); + const resolve = (sha) => ({ who: { "7fbe8897cee6ed522d1f52a2124c4109eb101543": "@blafourcade", dccbef25b43943904ec9a3cee3b5c2056d2cfdd6: "@alexsoyes" }[sha] }); + + const credited = credit(body, resolve); + + assert.equal(credited, [`${LINE_A} (@blafourcade)`, `${LINE_B} (@alexsoyes)`].join("\n")); +}); + +test("T2: a repeated contributor, credited on each line", () => { + const body = [LINE_A, LINE_B].join("\n"); + const resolve = nonMerge("@blafourcade"); + + const credited = credit(body, resolve); + + assert.equal(credited, [`${LINE_A} (@blafourcade)`, `${LINE_B} (@blafourcade)`].join("\n")); +}); + +test("T3: a bot, credited as @dependabot[bot]", () => { + const resolve = nonMerge("@dependabot[bot]"); + + const credited = credit(LINE_DEPENDABOT, resolve); + + assert.equal(credited, `${LINE_DEPENDABOT} (@dependabot[bot])`); +}); + +test("T4: a line ending in closes #N, credited after it", () => { + const resolve = nonMerge("@blafourcade"); + + const credited = credit(LINE_CLOSES, resolve); + + assert.equal(credited, `${LINE_CLOSES} (@blafourcade)`); +}); + +test("T5: a line without a commit SHA, untouched", () => { + const body = [HEADER, LINE_NO_SHA].join("\n"); + const resolve = () => { + throw new Error("resolve must not be called for a line with no commit SHA"); + }; + + const credited = credit(body, resolve); + + assert.equal(credited, body); +}); + +test("T6: a line already credited, untouched, and re-running never duplicates", () => { + const resolve = nonMerge("@blafourcade"); + const once = credit(LINE_A, resolve); + + const twice = credit(once, resolve); + + assert.equal(twice, once); + assert.equal(twice, `${LINE_A} (@blafourcade)`); +}); + +test("T6 (bot, brackets in the credit): re-running a dependabot-credited line never duplicates", () => { + const resolve = nonMerge("@dependabot[bot]"); + const once = credit(LINE_DEPENDABOT, resolve); + + const twice = credit(once, resolve); + + assert.equal(twice, once); +}); + +test("T8: a no-login name containing parentheses is not re-credited on a second pass", () => { + const resolve = nonMerge("Jane (JD) Doe"); + const once = credit(LINE_A, resolve); + + const twice = credit(once, resolve); + + assert.equal(twice, once); + assert.equal(twice, `${LINE_A} (Jane (JD) Doe)`); +}); + +test("T8 (closes tail): a no-login name containing parentheses is not re-credited after a closes-tail line", () => { + const resolve = nonMerge("Jane (JD) Doe"); + const once = credit(LINE_CLOSES, resolve); + + const twice = credit(once, resolve); + + assert.equal(twice, once); + assert.equal(twice, `${LINE_CLOSES} (Jane (JD) Doe)`); +}); + +test("T7: no login resolves, the name is appended without @", () => { + const resolve = nonMerge("Alex Soyer"); + + const credited = credit(LINE_A, resolve); + + assert.equal(credited, `${LINE_A} (Alex Soyer)`); +}); + +test("mutation guard: credit() is idempotent on a full multi-section body mixing every shape above", () => { + const SHA_TO_WHO = { + "7fbe8897cee6ed522d1f52a2124c4109eb101543": "@blafourcade", + dccbef25b43943904ec9a3cee3b5c2056d2cfdd6: "Alex Soyer", + "07a2364e282bc0aee1750250ee5b7594a6a5fe3a": "@dependabot[bot]", + "4531b7466a03cd136764540e55f5c867dbdcafb5": "@blafourcade", + }; + const resolve = (sha) => ({ who: SHA_TO_WHO[sha] }); + const body = [HEADER, LINE_A, LINE_B, LINE_DEPENDABOT, LINE_CLOSES].join("\n"); + + const once = credit(body, resolve); + const twice = credit(once, resolve); + + assert.equal(twice, once); +}); + +test("tagsFromOutputs: root path reads bare tag_name, other paths read --tag_name", () => { + const outputs = { + paths_released: JSON.stringify([".", "cli", "plugins/aidd-dev"]), + tag_name: "v5.10.0", + "cli--tag_name": "cli-v5.3.0", + "plugins/aidd-dev--tag_name": "aidd-dev-v2.5.0", + }; + + assert.deepEqual(tagsFromOutputs(outputs), ["v5.10.0", "cli-v5.3.0", "aidd-dev-v2.5.0"]); +}); + +test("tagsFromOutputs: no paths released is an empty list", () => { + assert.deepEqual(tagsFromOutputs({ paths_released: "" }), []); + assert.deepEqual(tagsFromOutputs({ paths_released: "[]" }), []); +}); + +// --- the merge-commit-body-duplicate safety net -------------------------- +// Why credit() also drops a merge commit's spurious twin: + +// Real twin pair from aidd-context-v1.0.1: 7f57ec9 (merge) duplicates 5594ec8. +const TWIN_TEXT = "* **aidd-context:** document seven artifacts and tool-agnostic wording"; +const TWIN_MERGE_LINE = `${TWIN_TEXT} ([7f57ec9](https://github.com/ai-driven-dev/framework/commit/7f57ec97e6fa515b07d817d9f692ffdecc1c0a56))`; +const TWIN_REAL_LINE = `${TWIN_TEXT} ([5594ec8](https://github.com/ai-driven-dev/framework/commit/5594ec8a590caed0ca1d96e945cdee7460216c5f))`; + +function twinResolver({ mergeWho = "@blafourcade", realWho = "@blafourcade", prAuthor = "@blafourcade" } = {}) { + return (sha) => { + if (sha === "7f57ec97e6fa515b07d817d9f692ffdecc1c0a56") return { who: mergeWho, isMerge: true, prAuthor }; + if (sha === "5594ec8a590caed0ca1d96e945cdee7460216c5f") return { who: realWho, isMerge: false }; + throw new Error(`unexpected sha ${sha}`); + }; +} + +test("twin removed: a merge commit's bullet is dropped when a plain-commit twin carries the same text", () => { + const body = [TWIN_MERGE_LINE, TWIN_REAL_LINE].join("\n"); + + const credited = credit(body, twinResolver({ realWho: "@alexsoyes" })); + + assert.equal(credited, `${TWIN_REAL_LINE} (@alexsoyes)`); +}); + +test("twin removed: order in the body does not matter", () => { + const body = [TWIN_REAL_LINE, TWIN_MERGE_LINE].join("\n"); + + const credited = credit(body, twinResolver({ realWho: "@alexsoyes" })); + + assert.equal(credited, `${TWIN_REAL_LINE} (@alexsoyes)`); +}); + +test("twin removed: still detected when the surviving twin is already credited", () => { + // The real line may already be credited; the merge twin still goes. + const creditedRealLine = `${TWIN_REAL_LINE} (@alexsoyes)`; + const body = [TWIN_MERGE_LINE, creditedRealLine].join("\n"); + + const credited = credit(body, twinResolver()); + + assert.equal(credited, creditedRealLine); +}); + +test("lone merge line: no twin, kept and credited to its pull request's author", () => { + const resolve = (sha) => { + assert.equal(sha, "7f57ec97e6fa515b07d817d9f692ffdecc1c0a56"); + return { who: "@whoever-merged", isMerge: true, prAuthor: "@therealauthor" }; + }; + + const credited = credit(TWIN_MERGE_LINE, resolve); + + assert.equal(credited, `${TWIN_MERGE_LINE} (@therealauthor)`); +}); + +test("lone merge line: falls back to the commit author when prAuthor resolves falsy", () => { + const resolve = () => ({ who: "@whoever-merged", isMerge: true, prAuthor: "" }); + + const credited = credit(TWIN_MERGE_LINE, resolve); + + assert.equal(credited, `${TWIN_MERGE_LINE} (@whoever-merged)`); +}); + +test("non-merge lines: two commits sharing identical text are both kept (e.g. a cherry-pick)", () => { + // This is the test that goes red the moment the merge check is dropped from the twin rule: + const otherRealLine = `${TWIN_TEXT} ([aaaaaaa](https://github.com/ai-driven-dev/framework/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa))`; + const body = [TWIN_REAL_LINE, otherRealLine].join("\n"); + const resolve = (sha) => ({ who: sha === "5594ec8a590caed0ca1d96e945cdee7460216c5f" ? "@alexsoyes" : "@blafourcade", isMerge: false }); + + const credited = credit(body, resolve); + + assert.equal(credited, [`${TWIN_REAL_LINE} (@alexsoyes)`, `${otherRealLine} (@blafourcade)`].join("\n")); +}); + +test("all-merge twin group: no plain-commit twin exists, both merge bullets are kept untouched", () => { + const otherMergeLine = `${TWIN_TEXT} ([bbbbbbb](https://github.com/ai-driven-dev/framework/commit/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb))`; + const body = [TWIN_MERGE_LINE, otherMergeLine].join("\n"); + const resolve = () => ({ who: "@whoever-merged", isMerge: true, prAuthor: "@therealauthor" }); + + const credited = credit(body, resolve); + + assert.equal(credited, [`${TWIN_MERGE_LINE} (@therealauthor)`, `${otherMergeLine} (@therealauthor)`].join("\n")); +}); + +test("second pass is a no-op after a twin was removed and the survivor credited", () => { + const body = [TWIN_MERGE_LINE, TWIN_REAL_LINE].join("\n"); + const resolve = twinResolver({ realWho: "@alexsoyes" }); + + const once = credit(body, resolve); + const twice = credit(once, resolve); + + assert.equal(twice, once); +}); + +// --- creditReleases(): reads, credits and writes back only what changed -- + +test("creditReleases: an already-credited tag's body is read but never written", () => { + const outputs = { paths_released: JSON.stringify(["."]), tag_name: "v1.0.0" }; + const bodies = { "v1.0.0": `${LINE_A} (@blafourcade)` }; + const writes = []; + const read = (tag) => bodies[tag]; + const write = (tag, body) => writes.push({ tag, body }); + const resolve = nonMerge("@blafourcade"); + + creditReleases("owner/repo", outputs, { read, resolve, write }); + + assert.deepEqual(writes, []); +}); + +test("creditReleases: a tag whose body changes is written exactly once, with the credited body", () => { + const outputs = { paths_released: JSON.stringify([".", "cli"]), tag_name: "v1.0.0", "cli--tag_name": "cli-v1.0.0" }; + const bodies = { + "v1.0.0": LINE_A, + "cli-v1.0.0": `${LINE_A} (@blafourcade)`, // already credited: must not be written again + }; + const writes = []; + const read = (tag) => bodies[tag]; + const write = (tag, body) => writes.push({ tag, body }); + const resolve = nonMerge("@blafourcade"); + + creditReleases("owner/repo", outputs, { read, resolve, write }); + + assert.deepEqual(writes, [{ tag: "v1.0.0", body: `${LINE_A} (@blafourcade)` }]); +}); + +// --- who() / isMerge() / prCredit() on the gh api replies' actual shape -- +// Regression: + +test("who: a commit with a login is credited as @login", () => { + assert.equal(who('{"login":"blafourcade","name":"Baptiste Lafourcade","parents":1}'), "@blafourcade"); +}); + +test("who: a bot login is credited as @dependabot[bot]", () => { + assert.equal(who('{"login":"dependabot[bot]","name":"dependabot[bot]","parents":1}'), "@dependabot[bot]"); +}); + +test("who: no login resolves to the plain commit-author name, not @Name", () => { + assert.equal(who('{"login":"","name":"Alex Soyer","parents":1}'), "Alex Soyer"); +}); + +test("isMerge: a single-parent commit is not a merge commit", () => { + assert.equal(isMerge('{"login":"","name":"Alex Soyer","parents":1}'), false); +}); + +test("isMerge: a two-parent commit is a merge commit", () => { + assert.equal(isMerge('{"login":"","name":"Alex Soyer","parents":2}'), true); +}); + +test("prCredit: the pull request's author is credited over the commit author", () => { + const commitReply = '{"login":"blafourcade","name":"Baptiste Lafourcade","parents":2}'; + const pullsReply = JSON.stringify([{ user: { login: "alexsoyes" } }]); + + assert.equal(prCredit(commitReply, pullsReply), "@alexsoyes"); +}); + +test("prCredit: falls back to the commit author when the pulls reply names no pull request", () => { + const commitReply = '{"login":"blafourcade","name":"Baptiste Lafourcade","parents":2}'; + + assert.equal(prCredit(commitReply, "[]"), "@blafourcade"); +}); + +// --- CLI entry: a bad or missing RELEASE_OUTPUTS fails loud, not silent -- +// Regression: +const SCRIPT = path.join(root, "scripts/credit-release-authors.cjs"); + +function runScript(env) { + return spawnSync(process.execPath, [SCRIPT, "example/none"], { env, encoding: "utf8" }); +} + +test("CLI: RELEASE_OUTPUTS missing from the environment exits non-zero with a clear message", () => { + const env = { ...process.env }; + delete env.RELEASE_OUTPUTS; + + const result = runScript(env); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /RELEASE_OUTPUTS/); + assert.match(result.stderr, /missing/i); +}); + +test("CLI: RELEASE_OUTPUTS holding unparseable JSON exits non-zero with a clear message", () => { + const env = { ...process.env, RELEASE_OUTPUTS: "{not json" }; + + const result = runScript(env); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /RELEASE_OUTPUTS/); + assert.match(result.stderr, /valid JSON/i); +}); + +test("CLI: RELEASE_OUTPUTS with no paths_released key exits non-zero with a clear message", () => { + const env = { ...process.env, RELEASE_OUTPUTS: JSON.stringify({ release_created: "false" }) }; + + const result = runScript(env); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /paths_released/); +}); + +// --- ci.yml wiring ------------------------------------------------------- + +const workflow = () => yaml.load(fs.readFileSync(path.join(root, ".github/workflows/ci.yml"), "utf8")); + +const creditStep = () => { + const steps = workflow().jobs["release-please"].steps; + return steps.find((step) => step.run && /credit-release-authors\.cjs/.test(step.run)); +}; + +test("W1: the credit step runs only when releases were created", () => { + const step = creditStep(); + + assert.ok(step, "no step in the release-please job runs credit-release-authors.cjs"); + assert.equal(step.if, "${{ steps.release.outputs.releases_created == 'true' }}"); +}); + +test("W2: the credit step runs after the release step, with the App token", () => { + const steps = workflow().jobs["release-please"].steps; + const releaseIndex = steps.findIndex((step) => step.id === "release"); + const creditIndex = steps.findIndex((step) => step.run && /credit-release-authors\.cjs/.test(step.run)); + + assert.ok(releaseIndex >= 0 && creditIndex >= 0); + assert.ok(creditIndex > releaseIndex, "the credit step must run after the release step"); + assert.equal(creditStep().env.GH_TOKEN, "${{ steps.app-token.outputs.token }}"); +}); + +test("W4: the credit step's RELEASE_OUTPUTS carries steps.release.outputs verbatim", () => { + assert.equal(creditStep().env.RELEASE_OUTPUTS, "${{ toJSON(steps.release.outputs) }}"); +}); + +test("W5: a checkout step runs immediately before the credit step, so the script exists on disk", () => { + const steps = workflow().jobs["release-please"].steps; + const creditIndex = steps.findIndex((step) => step.run && /credit-release-authors\.cjs/.test(step.run)); + const precedingStep = steps[creditIndex - 1]; + + assert.ok(precedingStep, "no step precedes the credit step"); + assert.match(precedingStep.uses || "", /actions\/checkout@/, "the step immediately before the credit step must be a checkout"); +}); + +test("W3: a crediting failure never blocks the release's build and publish jobs", () => { + assert.equal(creditStep()["continue-on-error"], true); +}); + +test("W6: the checkout step added for crediting never blocks build and publish either", () => { + const steps = workflow().jobs["release-please"].steps; + const creditIndex = steps.findIndex((step) => step.run && /credit-release-authors\.cjs/.test(step.run)); + const checkoutStep = steps[creditIndex - 1]; + + assert.ok(checkoutStep, "no step precedes the credit step"); + assert.equal(checkoutStep["continue-on-error"], true, "a checkout failure must not fail the release-please job either"); +}); diff --git a/scripts/__tests__/fixtures/architecture-rules/agent-permission-list.md b/scripts/__tests__/fixtures/architecture-rules/agent-permission-list.md new file mode 100644 index 000000000..71057c071 --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/agent-permission-list.md @@ -0,0 +1,12 @@ +# Role + +You are the reviewer. + +# Behavior + +- Do your job. + +# Skills you may invoke + +- `/aidd-fixture-b:01-noop` +- `/aidd-fixture-c:02-other` diff --git a/scripts/__tests__/fixtures/architecture-rules/assets-note.md b/scripts/__tests__/fixtures/architecture-rules/assets-note.md new file mode 100644 index 000000000..891150a5d --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/assets-note.md @@ -0,0 +1,3 @@ +# Recipe + +Type `/aidd-fixture-b:01-noop` to trigger the sibling flow from your terminal. diff --git a/scripts/__tests__/fixtures/architecture-rules/bare-address-skill/SKILL.md b/scripts/__tests__/fixtures/architecture-rules/bare-address-skill/SKILL.md new file mode 100644 index 000000000..99822bb50 --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/bare-address-skill/SKILL.md @@ -0,0 +1,10 @@ +# Bare Address + +Declare it the same way `aidd-fixture-b:01-noop` does. Do not confuse this with +some-aidd-fixture-b:02-other, which names no real plugin and must stay silent. + +## Actions + +| Action | Does | +| --- | --- | +| `step` | does the one thing | diff --git a/scripts/__tests__/fixtures/architecture-rules/clean-skill/SKILL.md b/scripts/__tests__/fixtures/architecture-rules/clean-skill/SKILL.md new file mode 100644 index 000000000..1712689ab --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/clean-skill/SKILL.md @@ -0,0 +1,15 @@ +# Clean + +A minimal skill with no sibling address and one action. + +## Actions + +Run `step`. + +| Action | Does | +| --- | --- | +| step | does the one thing | + +## Notes + +Nothing else here. diff --git a/scripts/__tests__/fixtures/architecture-rules/cross-address-skill/SKILL.md b/scripts/__tests__/fixtures/architecture-rules/cross-address-skill/SKILL.md new file mode 100644 index 000000000..ee2e8806a --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/cross-address-skill/SKILL.md @@ -0,0 +1,11 @@ +# Cross Address + +This skill wrongly calls a sibling plugin directly. + +## Actions + +Run `step`, then hand off to `/aidd-fixture-b:01-noop` for the rest. + +| Action | Does | +| --- | --- | +| step | does the one thing | diff --git a/scripts/__tests__/fixtures/architecture-rules/deleted-row-masked-by-prose-skill/SKILL.md b/scripts/__tests__/fixtures/architecture-rules/deleted-row-masked-by-prose-skill/SKILL.md new file mode 100644 index 000000000..4e8fc0967 --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/deleted-row-masked-by-prose-skill/SKILL.md @@ -0,0 +1,9 @@ +# Multi Step + +## Actions + +Run them in order, `01 → 04`. The plan is the culmination. + +| # | Action | Role | +| --- | --- | --- | +| 01 | `frame` | Frame it | diff --git a/scripts/__tests__/fixtures/architecture-rules/glossary-column-skill/SKILL.md b/scripts/__tests__/fixtures/architecture-rules/glossary-column-skill/SKILL.md new file mode 100644 index 000000000..a9959b5f3 --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/glossary-column-skill/SKILL.md @@ -0,0 +1,14 @@ +# Glossary column skill + +## Actions + +Nothing routes here. The table below defines words, it does not dispatch. + +| Term | Synonym | +| --- | --- | +| step | move | +| other | further | + +## Transversal rules + +- Nothing. diff --git a/scripts/__tests__/fixtures/architecture-rules/nested-action.md b/scripts/__tests__/fixtures/architecture-rules/nested-action.md new file mode 100644 index 000000000..701a6a7a6 --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/nested-action.md @@ -0,0 +1 @@ +Nested one level under `actions/`, this still names a sibling directly: `aidd-fixture-b:01-noop`. diff --git a/scripts/__tests__/fixtures/architecture-rules/nested-assets-skill/SKILL.md b/scripts/__tests__/fixtures/architecture-rules/nested-assets-skill/SKILL.md new file mode 100644 index 000000000..47b6d98b3 --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/nested-assets-skill/SKILL.md @@ -0,0 +1,5 @@ +# Nested Under Assets + +This file sits under an `assets/` segment even though it is named `SKILL.md` and would +otherwise be governed at this depth. Naming a sibling here, `aidd-fixture-b:01-noop`, must +stay silent. diff --git a/scripts/__tests__/fixtures/architecture-rules/nested-reference.md b/scripts/__tests__/fixtures/architecture-rules/nested-reference.md new file mode 100644 index 000000000..1875629ed --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/nested-reference.md @@ -0,0 +1,4 @@ +# Nested reference + +Nested two levels under `references/`, this still names a sibling directly: +`aidd-fixture-b:01-noop`. diff --git a/scripts/__tests__/fixtures/architecture-rules/nested-skill/SKILL.md b/scripts/__tests__/fixtures/architecture-rules/nested-skill/SKILL.md new file mode 100644 index 000000000..4b93cd5be --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/nested-skill/SKILL.md @@ -0,0 +1,4 @@ +# Nested Skill + +Nested one level deeper than usual, this still names a sibling directly: +`aidd-fixture-b:01-noop`. diff --git a/scripts/__tests__/fixtures/architecture-rules/no-actions-section-skill/SKILL.md b/scripts/__tests__/fixtures/architecture-rules/no-actions-section-skill/SKILL.md new file mode 100644 index 000000000..a9022d99d --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/no-actions-section-skill/SKILL.md @@ -0,0 +1,7 @@ +# No actions section skill + +This skill ships an action file and never declares a router for it. + +## Transversal rules + +- Nothing. diff --git a/scripts/__tests__/fixtures/architecture-rules/onboard-menu.md b/scripts/__tests__/fixtures/architecture-rules/onboard-menu.md new file mode 100644 index 000000000..2c8cbd610 --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/onboard-menu.md @@ -0,0 +1,5 @@ +# Onboard routing menu + +| Situation | Route to | +| --- | --- | +| start new work | `aidd-orchestrator:01-sdlc` | diff --git a/scripts/__tests__/fixtures/architecture-rules/orchestrator-incoherent-skill/SKILL.md b/scripts/__tests__/fixtures/architecture-rules/orchestrator-incoherent-skill/SKILL.md new file mode 100644 index 000000000..32addd9da --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/orchestrator-incoherent-skill/SKILL.md @@ -0,0 +1,8 @@ +# Orchestrated Router + +Dispatches broadly across plugins. + +## Actions + +Hand off to `/aidd-fixture-a:02-cross-address` depending on intent, and say nothing else about +what this skill itself provides. diff --git a/scripts/__tests__/fixtures/architecture-rules/orchestrator-skill/SKILL.md b/scripts/__tests__/fixtures/architecture-rules/orchestrator-skill/SKILL.md new file mode 100644 index 000000000..6e9d99c02 --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/orchestrator-skill/SKILL.md @@ -0,0 +1,7 @@ +# Orchestrated Router + +Dispatches broadly across plugins. + +## Actions + +Route to `/aidd-fixture-a:02-cross-address` or `/aidd-fixture-b:01-noop` depending on intent. diff --git a/scripts/__tests__/fixtures/architecture-rules/stem-substring-skill/SKILL.md b/scripts/__tests__/fixtures/architecture-rules/stem-substring-skill/SKILL.md new file mode 100644 index 000000000..b2b7bbf35 --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/stem-substring-skill/SKILL.md @@ -0,0 +1,7 @@ +# Two similarly named actions + +## Actions + +| # | Action | Facet | +| --- | --- | --- | +| 02 | `assert-architecture` | Report where the code breaks the documented architecture | diff --git a/scripts/__tests__/fixtures/architecture-rules/unnamed-action-skill/SKILL.md b/scripts/__tests__/fixtures/architecture-rules/unnamed-action-skill/SKILL.md new file mode 100644 index 000000000..1347d874a --- /dev/null +++ b/scripts/__tests__/fixtures/architecture-rules/unnamed-action-skill/SKILL.md @@ -0,0 +1,9 @@ +# Two Steps + +## Actions + +Run `step-one` to begin. + +| Action | Does | +| --- | --- | +| step-one | begins | diff --git a/scripts/__tests__/gate-witness.test.js b/scripts/__tests__/gate-witness.test.js new file mode 100644 index 000000000..75cac1fcb --- /dev/null +++ b/scripts/__tests__/gate-witness.test.js @@ -0,0 +1,102 @@ +const assert = require("node:assert/strict"); +const { execFileSync, spawnSync } = require("node:child_process"); +const { createHash } = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const WITNESS = path.resolve(__dirname, "../gate-witness.js"); +const CLEAN_ENV = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))); + +function repo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gate-witness-")); + const git = (...args) => execFileSync("git", args, { cwd: dir, env: CLEAN_ENV, stdio: "pipe" }); + git("init", "-q"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + fs.writeFileSync(path.join(dir, "a.txt"), "a\n"); + fs.writeFileSync(path.join(dir, "b.txt"), "b\n"); + git("add", "-A"); + git("commit", "-q", "-m", "init"); + const runs = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "gate-witness-runs-")), "count"); + fs.writeFileSync(runs, ""); + return { dir, git, runs }; +} + +function witness(r, commandLine, name = "suite") { + return spawnSync(process.execPath, [WITNESS, name, "--", commandLine], { cwd: r.dir, env: CLEAN_ENV, encoding: "utf8" }); +} + +function commandLine(r, script) { + const file = path.join(path.dirname(r.runs), `${createHash("sha256").update(script).digest("hex")}.js`); + fs.writeFileSync(file, script); + return [process.execPath, file, r.runs].map((part) => `"${part}"`).join(" "); +} + +const gate = (r, script, name) => witness(r, commandLine(r, script), name); + +const COUNT = "require('fs').appendFileSync(process.argv[2], 'x')"; +const runsOf = (r) => fs.readFileSync(r.runs, "utf8").length; + +test("skips a gate on a tree that already passed it, and says so", () => { + const r = repo(); + assert.equal(gate(r, COUNT).status, 0); + const second = gate(r, COUNT); + assert.equal(second.status, 0); + assert.equal(runsOf(r), 1); + assert.match(second.stdout + second.stderr, /already passed/); +}); + +test("runs again after a tracked file changes, staged or not", () => { + const r = repo(); + gate(r, COUNT); + fs.writeFileSync(path.join(r.dir, "a.txt"), "a changed\n"); + gate(r, COUNT); + r.git("add", "a.txt"); + fs.writeFileSync(path.join(r.dir, "a.txt"), "a changed\n"); + gate(r, COUNT); + assert.equal(runsOf(r), 3); +}); + +test("runs again after a file is added, left untracked, or removed", () => { + const r = repo(); + gate(r, COUNT); + fs.writeFileSync(path.join(r.dir, "new.txt"), "new\n"); + gate(r, COUNT); + r.git("add", "new.txt"); + gate(r, COUNT); + r.git("rm", "-q", "b.txt"); + gate(r, COUNT); + assert.equal(runsOf(r), 4); +}); + +test("stamps nothing when the tree changed while the gate ran, even once it changes back", () => { + const r = repo(); + const editsOnItsFirstRun = `${COUNT}; if (require('fs').readFileSync(process.argv[2], 'utf8') === 'x') require('fs').writeFileSync('a.txt', 'edited during the run')`; + assert.equal(gate(r, editsOnItsFirstRun).status, 0); + fs.writeFileSync(path.join(r.dir, "a.txt"), "a\n"); + gate(r, editsOnItsFirstRun); + assert.equal(runsOf(r), 2); +}); + +test("passes a failure through and stamps nothing", () => { + const r = repo(); + assert.equal(gate(r, `${COUNT}; process.exit(3)`).status, 3); + gate(r, COUNT); + assert.equal(runsOf(r), 2); +}); + +test("keeps one stamp per gate, so another gate still runs", () => { + const r = repo(); + gate(r, COUNT, "knip"); + gate(r, COUNT, "suite"); + assert.equal(runsOf(r), 2); +}); + +test("runs what follows -- as one shell command line, on every platform", () => { + const r = repo(); + const count = commandLine(r, COUNT); + assert.equal(witness(r, `${count} && ${count}`).status, 0); + assert.equal(runsOf(r), 2); +}); diff --git a/scripts/__tests__/main-merges-by-merge-commit.test.js b/scripts/__tests__/main-merges-by-merge-commit.test.js new file mode 100644 index 000000000..0d4c6d7c7 --- /dev/null +++ b/scripts/__tests__/main-merges-by-merge-commit.test.js @@ -0,0 +1,53 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const yaml = require("js-yaml"); + +const root = path.resolve(__dirname, "../.."); + +const rawFile = (relativePath) => fs.readFileSync(path.join(root, relativePath), "utf8"); +const workflow = (relativePath) => yaml.load(rawFile(relativePath)); +const ruleset = () => JSON.parse(rawFile(".github/rulesets/main.json")); + +const pullRequestRule = () => ruleset().rules.find((rule) => rule.type === "pull_request"); + +// The steps that merge a pull request into `main`. +const mergesIntoMain = () => [ + { + label: "ci.yml release-please job, Auto-merge the Release PR", + run: workflow(".github/workflows/ci.yml").jobs["release-please"].steps.find( + (step) => step.name === "Auto-merge the Release PR", + ).run, + }, + { + label: "promote.yml, Open the promote PR, enable merge auto-merge", + run: workflow(".github/workflows/promote.yml").jobs.promote.steps.find( + (step) => step.name === "Open the promote PR, enable merge auto-merge", + ).run, + }, +]; + +test("T1: main.json's pull_request rule allows only the merge method", () => { + const rule = pullRequestRule(); + + assert.ok(rule, "no pull_request rule in .github/rulesets/main.json"); + assert.deepEqual(rule.parameters.allowed_merge_methods, ["merge"]); +}); + +test("T2: ci.yml merges the release PR into main with --merge", () => { + const step = mergesIntoMain().find((s) => s.label.startsWith("ci.yml")); + + assert.match(step.run, /gh pr merge .*--merge\b/); +}); + +test("T3: no step that merges a pull request into main uses --squash or --rebase", () => { + for (const step of mergesIntoMain()) { + assert.doesNotMatch(step.run, /--squash\b/, `${step.label} must not squash into main`); + assert.doesNotMatch(step.run, /--rebase\b/, `${step.label} must not rebase into main`); + } +}); + +test(".github/rulesets/main.json stays valid JSON", () => { + assert.doesNotThrow(() => ruleset()); +}); diff --git a/scripts/__tests__/opencode-plugin.test.js b/scripts/__tests__/opencode-plugin.test.js index 321d3a18b..384f07c23 100644 --- a/scripts/__tests__/opencode-plugin.test.js +++ b/scripts/__tests__/opencode-plugin.test.js @@ -28,9 +28,10 @@ function makeTempDir(prefix) { return dir; } -// Mirrors what a real install delivers: opencode-plugin.js copied verbatim beside -// journal.cjs and lib/ (see plugin-content-translator.ts, flatHooksFiles) - not the -// source tree, so this exercises the exact sibling-file layout OpenCode's loader sees. +// Mirrors what a real install delivers: OpenCode's loader scans `plugin/` one level deep, so +// the build puts this plugin's own module there alone, renamed after the plugin, and every +// other hook script under `hooks//` (opencode-paths.ts) - not the source tree, where +// they are siblings, so this exercises the split layout the loader actually sees. function makeInstalledRepo() { const repo = makeTempDir("aidd-opencode-plugin-repo-"); execFileSync("git", ["init", "-q"], { cwd: repo, env: CLEAN_ENV }); @@ -43,13 +44,17 @@ function makeInstalledRepo() { JSON.stringify({ telemetry: { enabled: true, endpoint: "http://127.0.0.1:4318" } }) ); const pluginDir = path.join(repo, ".opencode", "plugin"); + const scriptsDir = path.join(repo, ".opencode", "hooks", "aidd-telemetry"); fs.mkdirSync(pluginDir, { recursive: true }); + fs.mkdirSync(scriptsDir, { recursive: true }); const hooksSrc = path.dirname(PLUGIN_SOURCE); for (const entry of fs.readdirSync(hooksSrc, { withFileTypes: true })) { if (entry.name === "hooks.json") continue; - fs.cpSync(path.join(hooksSrc, entry.name), path.join(pluginDir, entry.name), { - recursive: true, - }); + const loaderEntry = entry.name === path.basename(PLUGIN_SOURCE); + const target = loaderEntry + ? path.join(pluginDir, "aidd-telemetry.js") + : path.join(scriptsDir, entry.name); + fs.cpSync(path.join(hooksSrc, entry.name), target, { recursive: true }); } // A byte-identical `.mjs` twin, for these tests alone. // @@ -60,8 +65,8 @@ function makeInstalledRepo() { // Plain Node does consult it, and there is none to consult: nothing up this tree declares // one, so Node reaches the file as typeless, finds ESM syntax, and reparses. Naming the // extension explicitly is what these tests do instead, and it is the only difference. - const esmTwin = path.join(pluginDir, "opencode-plugin.mjs"); - fs.copyFileSync(path.join(pluginDir, "opencode-plugin.js"), esmTwin); + const esmTwin = path.join(pluginDir, "aidd-telemetry.mjs"); + fs.copyFileSync(path.join(pluginDir, "aidd-telemetry.js"), esmTwin); return { repo, pluginDir, esmTwin }; } diff --git a/scripts/__tests__/release-merges-empty-body.test.js b/scripts/__tests__/release-merges-empty-body.test.js new file mode 100644 index 000000000..e51cea74d --- /dev/null +++ b/scripts/__tests__/release-merges-empty-body.test.js @@ -0,0 +1,61 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const yaml = require("js-yaml"); + +/** Every merge this repository's own automation makes into `main` passes `--body ""`. */ + +const root = path.resolve(__dirname, "../.."); + +function loadWorkflow(relativePath) { + return yaml.load(fs.readFileSync(path.join(root, relativePath), "utf8")); +} + +/** Joins `\`-continued shell lines into one command per entry. */ +function logicalCommands(run) { + return run + .replace(/\\\r?\n\s*/gu, " ") + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); +} + +// --- ci.yml: the Release PR auto-merge ------------------------------------ + +test("ci.yml: the Release PR auto-merge command carries --body \"\"", () => { + const workflow = loadWorkflow(".github/workflows/ci.yml"); + const steps = workflow.jobs["release-please"].steps; + const step = steps.find((s) => s.name === "Auto-merge the Release PR"); + + assert.ok(step, "no 'Auto-merge the Release PR' step found in ci.yml's release-please job"); + const mergeCommand = logicalCommands(step.run).find((line) => line.startsWith("gh pr merge")); + assert.ok(mergeCommand, "no gh pr merge command found in the Auto-merge the Release PR step"); + assert.match(mergeCommand, /--body\s+""/u, `expected --body "" in: ${mergeCommand}`); +}); + +// --- promote.yml: the next -> main promotion merge ------------------------ + +test("promote.yml: the promote PR merge command carries --body \"\", isolated from gh pr create's own --body", () => { + const workflow = loadWorkflow(".github/workflows/promote.yml"); + const step = workflow.jobs.promote.steps.find((s) => s.name === "Open the promote PR, enable merge auto-merge"); + + assert.ok(step, "no 'Open the promote PR, enable merge auto-merge' step found in promote.yml"); + const commands = logicalCommands(step.run); + + const createCommand = commands.find((line) => line.startsWith("PR=$(gh pr create") || line.includes("gh pr create")); + assert.ok(createCommand, "no gh pr create command found"); + assert.match(createCommand, /--body\s+"Automated/u, "gh pr create is expected to keep its own descriptive --body"); + + const mergeCommand = commands.find((line) => line.startsWith("gh pr merge")); + assert.ok(mergeCommand, "no gh pr merge command found in the promote step"); + assert.match(mergeCommand, /--body\s+""/u, `expected --body "" in: ${mergeCommand}`); +}); + +test("promote.yml: the commitHeadline recorded-subject check is still present, untouched by the --body change", () => { + const workflow = loadWorkflow(".github/workflows/promote.yml"); + const step = workflow.jobs.promote.steps.find((s) => s.name === "Open the promote PR, enable merge auto-merge"); + + assert.match(step.run, /autoMergeRequest\.commitHeadline/u); + assert.match(step.run, /Auto-merge did not record the commit subject/u); +}); diff --git a/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js b/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js index 3f58f7be8..93ffd51bb 100644 --- a/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js +++ b/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js @@ -45,12 +45,14 @@ describe("smoke-real.sh drives --scope user with the tools the profiles support" assert.deepEqual([...scriptUserAiList()].sort(), [...supported].sort()); }); - it("leaves out the AI tool that declares neither, which setup --scope user refuses", () => { + it("leaves out AI tools that declare neither, which setup --scope user refuses", () => { const unsupported = aiToolIds().filter((id) => !declaresUserScopeActivation(id)); - assert.deepEqual(unsupported, ["opencode"]); - assert.ok( - !scriptUserAiList().includes("opencode"), - "smoke-real.sh's user_ai_list names opencode, which setup --scope user refuses outright" - ); + assert.deepEqual([...unsupported].sort(), ["kilo", "opencode"]); + for (const toolId of unsupported) { + assert.ok( + !scriptUserAiList().includes(toolId), + `smoke-real.sh's user_ai_list names ${toolId}, which setup --scope user refuses outright` + ); + } }); }); diff --git a/scripts/__tests__/smoke-scripts-inline-node-quoting.test.js b/scripts/__tests__/smoke-scripts-inline-node-quoting.test.js index 042019e9d..2d7add2fe 100644 --- a/scripts/__tests__/smoke-scripts-inline-node-quoting.test.js +++ b/scripts/__tests__/smoke-scripts-inline-node-quoting.test.js @@ -4,7 +4,11 @@ const path = require("node:path"); const test = require("node:test"); const root = path.resolve(__dirname, "../.."); -const SCRIPTS = ["cli/scripts/smoke-tools.sh", "cli/scripts/smoke-real.sh"]; +const SCRIPTS = [ + "cli/scripts/smoke-tools.sh", + "cli/scripts/smoke-real.sh", + "cli/scripts/smoke-collision.sh", +]; /** Each `node -e '...'` block, as the text between its opening and closing quote. */ function inlineNodeBlocks(script) { diff --git a/scripts/check-architecture-rules.js b/scripts/check-architecture-rules.js new file mode 100644 index 000000000..c59cd0df3 --- /dev/null +++ b/scripts/check-architecture-rules.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +/** + * The two architecture rules of issue #250, at commit time. The write-time hook only ever sees + * Claude Code; this sees every edit that reaches a commit, whichever tool or person made it. + * + * Paths given as arguments are checked; with none, the whole governed tree is. + * `--root ` moves both, so the refusal can be exercised against another tree. + */ + +"use strict"; + +const path = require("node:path"); + +const { describeFix, governedPaths, scan } = require("./lib/architecture-scan.js"); + +const DEFAULT_ROOT = path.resolve(__dirname, ".."); + +function parse(argv) { + const at = argv.indexOf("--root"); + if (at === -1) return { root: DEFAULT_ROOT, requested: argv.filter((a) => !a.startsWith("-")) }; + + const root = path.resolve(argv[at + 1] ?? "."); + const rest = [...argv.slice(0, at), ...argv.slice(at + 2)]; + return { root, requested: rest.filter((a) => !a.startsWith("-")) }; +} + +function main(argv) { + const { root, requested } = parse(argv); + const relative = (argument) => + path.relative(root, path.resolve(root, argument)).split(path.sep).join("/"); + const paths = requested.length > 0 ? requested.map(relative) : governedPaths(root); + const violations = scan(root, paths); + + if (violations.length === 0) { + console.log(`✅ Architecture rules: ${paths.length} governed file(s) checked, no violation`); + return 0; + } + + for (const violation of violations) { + console.error(`❌ ${violation.message}`); + console.error(` Fix: ${describeFix(violation)}.`); + } + console.error(`\n${violations.length} violation(s) across ${paths.length} checked file(s).`); + return 1; +} + +process.exitCode = main(process.argv.slice(2)); diff --git a/scripts/credit-release-authors.cjs b/scripts/credit-release-authors.cjs new file mode 100644 index 000000000..8a399cbfe --- /dev/null +++ b/scripts/credit-release-authors.cjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +/** + * Appends each release line's commit author as `(@login)`, or the name when no account, to the + * GitHub releases release-please just created. Also drops a merge commit's duplicate line + * (see credit()). + * + * Workaround: release-please's `include-commit-authors` is a no-op (googleapis/release-please#2761). + * Delete this script, its test and its ci.yml step once #2892 ships in the pinned action. + */ + +const { execFileSync } = require("node:child_process"); + +// Every entry links its full commit SHA: `([7fbe889](https://…/commit/<40 hex>))`. +const COMMIT_SHA = /\/commit\/([0-9a-f]{40})\)/; + +// The commit link; the text before it is the bullet itself. +const COMMIT_LINK = /\s\(\[[0-9a-f]+\]\(https:\/\/[^\s)]+\/commit\/[0-9a-f]{40}\)\)/; + +// Matched only after the commit link: a credit this script appended. Keeps re-runs a no-op. +const ALREADY_CREDITED = /\s\(/; + +/** The bullet's text before its commit link. */ +function bulletText(line) { + const link = line.match(COMMIT_LINK); + return link ? line.slice(0, link.index) : line; +} + +/** + * Credits each commit-linked line. A merge-commit line with a plain-commit twin is dropped (the + * repo puts the PR title in a merge commit's body, which release-please parses as a 2nd commit); + * a lone one is credited to its PR's author. `resolve(sha)` returns `{ who, isMerge, prAuthor }`. + */ +function credit(body, resolve) { + const lines = body.split("\n"); + + const items = lines.map((line) => { + const match = line.match(COMMIT_SHA); + if (!match) return { line, sha: null }; + return { line, sha: match[1], match, text: bulletText(line) }; + }); + + for (const item of items) { + if (item.sha) item.meta = resolve(item.sha) || {}; + } + + const groups = new Map(); + for (const item of items) { + if (!item.sha) continue; + if (!groups.has(item.text)) groups.set(item.text, []); + groups.get(item.text).push(item); + } + + const drop = new Set(); + for (const group of groups.values()) { + if (group.length < 2) continue; + const hasPlainCommitTwin = group.some((item) => !item.meta.isMerge); + if (!hasPlainCommitTwin) continue; // an all-merge twin group: nothing tells them apart + for (const item of group) { + if (item.meta.isMerge) drop.add(item); + } + } + + return items + .filter((item) => !drop.has(item)) + .map((item) => { + if (!item.sha) return item.line; + + const tail = item.line.slice(item.match.index + item.match[0].length); + if (ALREADY_CREDITED.test(tail)) return item.line; + + const who = item.meta.isMerge ? item.meta.prAuthor || item.meta.who : item.meta.who; + return who ? `${item.line} (${who})` : item.line; + }) + .join("\n"); +} + +/** Tags created this run: root as `tag_name`, other paths as `--tag_name`. */ +function tagsFromOutputs(outputs) { + const paths = JSON.parse(outputs.paths_released || "[]"); + return paths.map((releasedPath) => (releasedPath === "." ? outputs.tag_name : outputs[`${releasedPath}--tag_name`])).filter(Boolean); +} + +/** Thin `gh` wrapper so the transform above stays network-free and unit-testable. */ +function gh(...args) { + return execFileSync("gh", args, { encoding: "utf8" }).trim(); +} + +/** `@login`, or the author's name when the commit has no linked account. */ +function who(apiReply) { + const { login, name } = JSON.parse(apiReply); + return login ? `@${login}` : name; +} + +/** More than one parent: a merge commit. */ +function isMerge(apiReply) { + const { parents } = JSON.parse(apiReply); + return Number(parents) > 1; +} + +/** A merge commit's credit: its PR author, else the commit author. */ +function prCredit(commitReply, pullsReply) { + const prs = JSON.parse(pullsReply); + const login = prs[0] && prs[0].user && prs[0].user.login; + return login ? `@${login}` : who(commitReply); +} + +function resolverFor(repo) { + const cache = new Map(); + return (sha) => { + if (!cache.has(sha)) { + const commitReply = gh("api", `repos/${repo}/commits/${sha}`, "-q", '{login: (.author.login // ""), name: .commit.author.name, parents: (.parents | length)}'); + const merge = isMerge(commitReply); + cache.set(sha, { + who: who(commitReply), + isMerge: merge, + prAuthor: merge ? prCredit(commitReply, gh("api", `repos/${repo}/commits/${sha}/pulls`)) : "", + }); + } + return cache.get(sha); + }; +} + +/** Credits each tag; writes only changed bodies, and throws naming the tag on failure. */ +function creditReleases(repo, outputs, { read, resolve, write }) { + const tags = tagsFromOutputs(outputs); + + for (const tag of tags) { + let body; + try { + body = read(tag); + } catch (error) { + throw new Error(`credit-release-authors: could not read release ${tag}: ${error.message}`); + } + + let credited; + try { + credited = credit(body, resolve); + } catch (error) { + throw new Error(`credit-release-authors: could not resolve a commit author for release ${tag}: ${error.message}`); + } + if (credited === body) { + console.log(`${tag}: unchanged`); + continue; + } + + try { + write(tag, credited); + } catch (error) { + throw new Error(`credit-release-authors: could not write release ${tag}: ${error.message}`); + } + console.log(`${tag}: credited`); + } +} + +/** Wires `creditReleases` to the real `gh` CLI. The one place this module touches the network. */ +function main(repo, outputs) { + creditReleases(repo, outputs, { + read: (tag) => gh("release", "view", tag, "-R", repo, "--json", "body", "-q", ".body"), + resolve: resolverFor(repo), + write: (tag, body) => execFileSync("gh", ["release", "edit", tag, "-R", repo, "--notes-file", "-"], { input: body, encoding: "utf8" }), + }); +} + +module.exports = { credit, tagsFromOutputs, who, isMerge, prCredit, creditReleases }; + +if (require.main === module) { + const repo = process.argv[2]; + if (!repo) { + console.error("usage: credit-release-authors.cjs (reads RELEASE_OUTPUTS from the environment)"); + process.exit(1); + } + let outputs; + try { + outputs = JSON.parse(process.env.RELEASE_OUTPUTS || ""); + } catch (error) { + console.error(`credit-release-authors: RELEASE_OUTPUTS is missing or not valid JSON: ${error.message}`); + process.exit(1); + } + if (!("paths_released" in outputs)) { + console.error("credit-release-authors: RELEASE_OUTPUTS carries no paths_released — nothing to credit"); + process.exit(1); + } + try { + main(repo, outputs); + } catch (error) { + console.error(error.message); + process.exit(1); + } +} diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index 71a0390a3..29cb7d18c 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -30,10 +30,10 @@ HAVE_MANAGED_OPENCODE=0; command -v aidd-opencode-reload >/dev/null 2>&1 && HAVE build_tool() { local tool="$1" rm -rf "$BUILD/$tool"; mkdir -p "$BUILD/$tool" - local mode=() - [ "$tool" != opencode ] || mode=(--flat) - npx --yes "@ai-driven-dev/cli@${AIDD_CLI_VERSION}" framework build \ - --source "$FW" --target "$tool" --out "$BUILD/$tool" "${mode[@]}" >/dev/null 2>&1 + local mode=marketplace + [ "$tool" != opencode ] || mode=flat + npx --yes "@ai-driven-dev/cli@${AIDD_CLI_VERSION}" translate "$FW" \ + --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 } sync_opencode_skills() { diff --git a/scripts/gate-witness.js b/scripts/gate-witness.js new file mode 100644 index 000000000..8ea099269 --- /dev/null +++ b/scripts/gate-witness.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * Runs a gate unless this exact tree already passed it. The witness is keyed on everything a + * gate reads from the checkout: the index, so `git add` and `git rm` count; unstaged edits to + * tracked files; and untracked files git does not ignore. It is taken before the run and saved + * only when the run is green and the tree is still the one it was taken on, so an edit made + * while the gate ran is never signed off. The command's own exit code passes through. + * + * What follows `--` is one shell command line, run by the platform's shell everywhere: on + * Windows only a shell can start `pnpm`, and a shell given separate arguments would re-split + * them unquoted, so the caller quotes once and every platform reads the same line. + * + * Usage: + * node scripts/gate-witness.js -- + */ +const { execFileSync, spawnSync } = require("node:child_process"); +const { createHash } = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const USAGE_EXIT = 2; + +function git(root, args, input) { + return execFileSync("git", args, { + cwd: root, + input, + maxBuffer: 1 << 30, + stdio: ["pipe", "pipe", "ignore"], + }); +} + +function treeKey(root, command) { + const hash = createHash("sha256"); + hash.update(JSON.stringify(command)); + hash.update(git(root, ["ls-files", "-s", "-z"])); + hash.update(git(root, ["diff", "-z", "--no-ext-diff", "--binary"])); + const untracked = git(root, ["ls-files", "-o", "--exclude-standard", "-z"]) + .toString("utf8") + .split("\0") + .filter(Boolean); + if (untracked.length > 0) { + hash.update(untracked.join("\0")); + hash.update(git(root, ["hash-object", "--stdin-paths"], untracked.join("\n"))); + } + return hash.digest("hex"); +} + +function readStamp(file) { + try { + return fs.readFileSync(file, "utf8"); + } catch { + return null; + } +} + +function main() { + const [name, separator, ...command] = process.argv.slice(2); + if (!/^[\w-]+$/.test(name ?? "") || separator !== "--" || command.length === 0) { + console.error("Usage: node scripts/gate-witness.js -- "); + return USAGE_EXIT; + } + const root = git(process.cwd(), ["rev-parse", "--show-toplevel"]).toString("utf8").trim(); + const gitPath = git(root, ["rev-parse", "--git-path", `aidd-gate-witness/${name}`]); + const stamp = path.resolve(root, gitPath.toString("utf8").trim()); + const before = treeKey(root, command); + if (readStamp(stamp) === before) { + console.log(`✓ ${name}: skipped, this exact tree already passed it.`); + return 0; + } + const result = spawnSync(command.join(" "), { stdio: "inherit", shell: true }); + if (result.status !== 0) return result.status ?? 1; + if (treeKey(root, command) !== before) { + console.log(`${name}: passed, but the tree changed while it ran, so it is not stamped.`); + return 0; + } + fs.mkdirSync(path.dirname(stamp), { recursive: true }); + fs.writeFileSync(stamp, before); + return 0; +} + +process.exitCode = main(); diff --git a/scripts/lib/architecture-rules.js b/scripts/lib/architecture-rules.js new file mode 100644 index 000000000..3ea9ade7a --- /dev/null +++ b/scripts/lib/architecture-rules.js @@ -0,0 +1,310 @@ +/** + * The two architecture rules of issue #250, as pure functions: the caller supplies the path, the + * prospective content and a SKILL.md's action file names, and nothing here reads the filesystem. + * + * Why each rule is shaped the way it is — the one-directional router check, the exemptions, the + * two table shapes read generously — is in the decisions table of + * `aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/plan.md`. + */ + +"use strict"; + +const ORCHESTRATOR_PLUGIN = "aidd-orchestrator"; + +/** Expires with #883, which makes 00-onboard resolve its providers at runtime. */ +const ONBOARD_EXEMPT_PREFIX = "plugins/aidd-context/skills/00-onboard/"; + +const ANY_HEADING = /^#{1,6}\s/; +const PERMISSION_LIST_HEADING = /^#{1,6}\s+Skills you may invoke\s*$/i; +const ACTIONS_HEADING = /^##\s+Actions\s*$/i; +const SECOND_LEVEL_HEADING = /^##\s+/; +const FENCE_MARKER = /^\s*(`{3,}|~{3,})/; +const TABLE_ROW = /^\s*\|/; + +const PLUGIN_ADDRESS = /(? { + PLUGIN_ADDRESS.lastIndex = 0; + let match; + while ((match = PLUGIN_ADDRESS.exec(line)) !== null) { + addresses.push({ line: index + 1, plugin: match[1], text: match[0] }); + } + }); + return addresses; +} + +/** An agent's `# Skills you may invoke` list names its providers on purpose. */ +function permissionListLines(lines) { + const listed = new Set(); + let inList = false; + lines.forEach((line, index) => { + if (ANY_HEADING.test(line)) inList = PERMISSION_LIST_HEADING.test(line); + else if (inList) listed.add(index + 1); + }); + return listed; +} + +function isExemptFromOrthogonality(filePath, owner) { + return owner === ORCHESTRATOR_PLUGIN || filePath.startsWith(ONBOARD_EXEMPT_PREFIX); +} + +function checkOrthogonality(filePath, content) { + const surface = classifyFile(filePath); + if (!surface || isExemptFromOrthogonality(filePath, surface.owner)) return []; + + const lines = toLines(content); + const exemptLines = surface.kind === "agent" ? permissionListLines(lines) : new Set(); + + return addressesIn(content) + .filter(({ plugin, line }) => plugin !== surface.owner && !exemptLines.has(line)) + .map(({ plugin, line, text }) => + violation( + "orthogonality", + filePath, + line, + plugin, + `${filePath}:${line} addresses sibling plugin "${plugin}" via "${text}"` + ) + ); +} + +// --- Reading a `## Actions` section -------------------------------------------------------- + +function fenceCharacter(line) { + return line.match(FENCE_MARKER)?.[1][0] ?? null; +} + +function hasUnterminatedFence(lines) { + let open = null; + for (const line of lines) { + const marker = fenceCharacter(line); + if (marker === null) continue; + open = open === null ? marker : open === marker ? null : open; + } + return open !== null; +} + +/** + * Every fenced line blanked, line count intact so a reported number still points at its line. A + * fence holds an example, never a router — except an unterminated one, which is not a fence. + */ +function withoutFences(lines) { + if (hasUnterminatedFence(lines)) return lines; + + let open = null; + return lines.map((line) => { + const marker = fenceCharacter(line); + if (open === null && marker === null) return line; + if (open === null) open = marker; + else if (marker === open) open = null; + return ""; + }); +} + +function actionsSection(lines) { + const headingIndex = lines.findIndex((line) => ACTIONS_HEADING.test(line)); + if (headingIndex === -1) return null; + + const after = lines.slice(headingIndex + 1); + const nextHeading = after.findIndex((line) => SECOND_LEVEL_HEADING.test(line)); + + return { + headingLine: headingIndex + 1, + startIndex: headingIndex + 1, + endIndex: nextHeading === -1 ? lines.length : headingIndex + 1 + nextHeading, + }; +} + +function tableCells(line) { + return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim()); +} + +/** Contiguous runs of table rows: one table's header never speaks for the next one's columns. */ +function tableBlocks(lines) { + const blocks = []; + let current = []; + + for (const line of lines) { + if (TABLE_ROW.test(line)) { + current.push(line); + continue; + } + if (current.length > 0) { + blocks.push(current); + current = []; + } + } + if (current.length > 0) blocks.push(current); + + return blocks; +} + +function isSeparatorRow(cells) { + return cells.length > 0 && cells.every((cell) => SEPARATOR_CELL.test(cell)); +} + +function declaredActionColumn(headerCells) { + return headerCells.findIndex((cell) => ACTION_COLUMN_HEADER.test(stripBackticks(cell))); +} + +// --- Rule two: router coherence ------------------------------------------------------------- + +/** + * Names cited from the column a table calls `Action`. A run of rows with no separator beneath it + * is that table resumed after a blank line, so it keeps the column its header declared. + */ +function citationsFromActionColumns(sectionLines) { + const cited = new Set(); + let actionColumn = -1; + + for (const block of tableBlocks(sectionLines)) { + const rows = block.map(tableCells); + const declaresItsOwnColumns = rows.length > 1 && isSeparatorRow(rows[1]); + + if (declaresItsOwnColumns) actionColumn = declaredActionColumn(rows[0]); + if (actionColumn === -1) continue; + + for (const cells of declaresItsOwnColumns ? rows.slice(1) : rows) { + if (isSeparatorRow(cells)) continue; + const cell = stripBackticks(cells[actionColumn] ?? ""); + if (CITATION_TOKEN.test(cell)) cited.add(cell.toLowerCase()); + } + } + return cited; +} + +function matchesOf(pattern, text) { + pattern.lastIndex = 0; + const found = new Set(); + let match; + while ((match = pattern.exec(text)) !== null) found.add(match[1].toLowerCase()); + return found; +} + +/** + * The three citation shapes. Only the `actions/.md` path is read through a fence, because + * `aidd-dev:10-todo` cites its one action that way; a backticked file name inside a fence is an + * example. A word loose in prose is never a citation. + */ +function citationsIn(blankedLines, rawLines) { + return new Set([ + ...citationsFromActionColumns(blankedLines), + ...matchesOf(ACTION_PATH, rawLines.join("\n")), + ...matchesOf(BACKTICKED_FILE_NAME, blankedLines.join("\n")), + ]); +} + +/** A stem two action files share cites neither: one row would otherwise cover both. */ +function ambiguousStems(actionFileNames) { + const seen = new Set(); + const shared = new Set(); + for (const name of actionFileNames) { + const stem = stemOf(name).toLowerCase(); + if (seen.has(stem)) shared.add(stem); + seen.add(stem); + } + return shared; +} + +function isCited(actionFileName, citations, sharedStems) { + const stem = stemOf(actionFileName).toLowerCase(); + return ( + citations.has(actionFileName.toLowerCase()) || + citations.has(actionFileName.replace(/\.md$/i, "").toLowerCase()) || + (!sharedStems.has(stem) && citations.has(stem)) + ); +} + +function checkRouterCoherence(filePath, content, actionFileNames) { + const surface = classifyFile(filePath); + if (!surface || surface.kind !== "skill") return []; + + const names = actionFileNames ?? []; + if (names.length === 0) return []; + + const rawLines = toLines(content); + const lines = withoutFences(rawLines); + const section = actionsSection(lines); + + if (!section) { + const message = `${filePath} has action files but no "## Actions" section`; + return [violation("router-coherence", filePath, 1, surface.owner, message)]; + } + + const { startIndex, endIndex, headingLine } = section; + const citations = citationsIn(lines.slice(startIndex, endIndex), rawLines.slice(startIndex, endIndex)); + const sharedStems = ambiguousStems(names); + + return names + .filter((name) => !isCited(name, citations, sharedStems)) + .map((name) => + violation( + "router-coherence", + filePath, + headingLine, + surface.owner, + `${filePath}:${headingLine} "## Actions" never names action file "${name}"` + ) + ); +} + +function checkArchitecture(filePath, content, actionFileNames) { + return [ + ...checkOrthogonality(filePath, content), + ...checkRouterCoherence(filePath, content, actionFileNames), + ]; +} + +module.exports = { + checkArchitecture, + checkOrthogonality, + checkRouterCoherence, + classifyFile, +}; diff --git a/scripts/lib/architecture-scan.js b/scripts/lib/architecture-scan.js new file mode 100644 index 000000000..886c469c6 --- /dev/null +++ b/scripts/lib/architecture-scan.js @@ -0,0 +1,84 @@ +/** + * The filesystem layer the two rules need: `architecture-rules.js` stays pure, and everything + * that reads a directory or a file lives here, so the rules can be decided without the engine + * ever learning what a directory is. + */ + +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const { checkArchitecture, classifyFile } = require("./architecture-rules.js"); + +const CITATION_SHAPES = + "a cell under a table header that reads Action, a fenced `actions/.md` path, or a " + + "backticked `.md` file name. A word in prose does not count"; + +/** Rule two needs the skill's action files; the engine never reads them itself. */ +function actionFileNames(relativePath, absolutePath) { + if (path.basename(relativePath) !== "SKILL.md") return undefined; + try { + return fs + .readdirSync(path.join(path.dirname(absolutePath), "actions")) + .filter((name) => name.endsWith(".md")); + } catch { + return []; + } +} + +function violationsForFile(relativePath, content, absolutePath) { + try { + const found = checkArchitecture(relativePath, content, actionFileNames(relativePath, absolutePath)); + return Array.isArray(found) ? found : []; + } catch { + return []; + } +} + +function describeFix({ rule, plugin }) { + return rule === "orthogonality" + ? `name the concept ${plugin} owns instead of addressing it directly` + : `cite every action file the skill provides in its "## Actions" section. A citation is ${CITATION_SHAPES}`; +} + +function markdownUnder(directory, root, found) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) markdownUnder(full, root, found); + else if (entry.name.endsWith(".md")) { + found.push(path.relative(root, full).split(path.sep).join("/")); + } + } + return found; +} + +/** Every governed path in the tree, for the whole-tree run CI does. */ +function governedPaths(root) { + const plugins = path.join(root, "plugins"); + if (!fs.existsSync(plugins)) return []; + return markdownUnder(plugins, root, []).filter((relativePath) => classifyFile(relativePath)); +} + +/** Violations across paths already on disk. A path that is not governed contributes none. */ +function scan(root, relativePaths) { + const violations = []; + for (const relativePath of relativePaths) { + if (!classifyFile(relativePath)) continue; + const absolutePath = path.join(root, relativePath); + let content; + try { + content = fs.readFileSync(absolutePath, "utf8"); + } catch { + continue; + } + violations.push(...violationsForFile(relativePath, content, absolutePath)); + } + return violations; +} + +module.exports = { + describeFix, + governedPaths, + scan, +};