From 0846bc9b3d3776961e03a2d7c199f65ac6338977 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 11 Aug 2026 09:28:23 +0200 Subject: [PATCH 01/10] Add the docs MCP server and a skill that reaches for it .mcp.json wires https://docs.pmnd.rs/api/mcp, which serves the official docs for the 11 pmndrs libraries it knows about. The skill is what makes Claude actually consult them instead of recalling a stale API from memory. It enforces reading docs://{lib}/index before get_page_content. That is a correctness rule, not just a token one: page paths differ per library, and the manifest the server ships documents zustand paths that 404. Measured before choosing the shape: an index costs 213-1294 tokens and a page 1200-4100, so a typical lookup lands at 3-5k. That fits the main context, so no subagent for now. Co-Authored-By: Claude Opus 5 --- .mcp.json | 8 ++++++++ README.md | 12 +++++++++--- skills/docs/SKILL.md | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 .mcp.json create mode 100644 skills/docs/SKILL.md diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..552ebc9 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "pmndrs": { + "type": "http", + "url": "https://docs.pmnd.rs/api/mcp" + } + } +} diff --git a/README.md b/README.md index 78a8f70..24a43e4 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,15 @@ servers — belongs here. What it actually carries is still being defined. ## Contents -Nothing yet — the manifests, the license and the agent-facing docs, and that -is deliberate. Components land as `agents/`, `skills/`, `commands/` and -`.mcp.json` once we agree on what the plugin is for. +- **`.mcp.json`** — the `pmndrs` MCP server (`https://docs.pmnd.rs/api/mcp`), + which serves the official docs for react-three-fiber, drei, zustand, xr, + uikit and the rest of the ecosystem. +- **`skills/docs`** — makes Claude actually reach for those docs instead of + answering from memory, and teaches it to read a library index before + fetching a page. + +More components land as `agents/` and `commands/` as we agree on what else +the plugin is for. ## Install diff --git a/skills/docs/SKILL.md b/skills/docs/SKILL.md new file mode 100644 index 0000000..523c1fb --- /dev/null +++ b/skills/docs/SKILL.md @@ -0,0 +1,39 @@ +--- +name: docs +description: Look up the official pmndrs documentation before answering anything about react-three-fiber (R3F), drei, zustand, xr, uikit, react-postprocessing, a11y, leva, prai or viverse — hooks, components, props, signatures, store patterns, migrations. Use it whenever code or an answer depends on one of these APIs, rather than recalling the API from memory. +--- + +# pmndrs docs + +The `pmndrs` MCP server serves the official docs from docs.pmnd.rs. Prefer it over +memory: these libraries move fast and recalled APIs go stale. + +## Always index first + +Never guess a page path — `get_page_content` only accepts paths that exist, and the +route shapes differ per library (`/api/hooks` for react-three-fiber, `/learn/guides/...` +for zustand, `/abstractions/...` for drei). Read the index resource, pick the path, +then fetch. + +1. `ReadMcpResourceTool` on `docs://{lib}/index` — one `{path} - {title}` per line, + 200–1,300 tokens. +2. `mcp__pmndrs__get_page_content` with `lib` and the exact `path` from step 1. + +`lib` is one of: `react-three-fiber`, `drei`, `zustand`, `a11y`, +`react-postprocessing`, `uikit`, `xr`, `docs`, `prai`, `viverse`, `leva`. Names are +case-sensitive. + +## Budget + +Pages run 1,200–4,000 tokens each. Fetch the one page that answers the question. If +three or more look relevant, read the index titles first and narrow — don't fetch the +set and sort it out afterwards. + +## Known gaps + +- `docs://pmndrs/manifest` is partly stale: it cites jotai and valtio (not served) and + zustand paths under `/docs/...` that 404. Trust the per-library index, not the manifest. +- The `xr`, `uikit` and `leva` indexes currently return empty. Say so and fall back to + WebFetch on docs.pmnd.rs rather than guessing paths. +- A bad path returns `MCP server error: Page not found: {path}` — re-read the index + instead of retrying variants. From ffbffd020e517a7bc834f6f9888679bb2929c49d Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 11 Aug 2026 09:37:49 +0200 Subject: [PATCH 02/10] Scope the docs skill to the libraries the MCP server can serve Probing every library in the tool enum: only react-three-fiber, drei, zustand and docs publish the llms-full.txt dump the server parses. The other seven (a11y, react-postprocessing, uikit, xr, prai, viverse, leva) 404 on it, so get_page_content always errors and their index resource comes back empty. Naming all eleven in the description would have the skill fire on questions it cannot answer, so it now claims only what it serves and points the rest at WebFetch. Server-side fix is pmndrs/docs#555; widen this back once it lands. Co-Authored-By: Claude Opus 5 --- README.md | 3 +-- skills/docs/SKILL.md | 34 +++++++++++++++++----------------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 24a43e4..fb893ac 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,7 @@ servers — belongs here. What it actually carries is still being defined. ## Contents - **`.mcp.json`** — the `pmndrs` MCP server (`https://docs.pmnd.rs/api/mcp`), - which serves the official docs for react-three-fiber, drei, zustand, xr, - uikit and the rest of the ecosystem. + which serves the official docs for react-three-fiber, drei and zustand. - **`skills/docs`** — makes Claude actually reach for those docs instead of answering from memory, and teaches it to read a library index before fetching a page. diff --git a/skills/docs/SKILL.md b/skills/docs/SKILL.md index 523c1fb..28685d7 100644 --- a/skills/docs/SKILL.md +++ b/skills/docs/SKILL.md @@ -1,6 +1,6 @@ --- name: docs -description: Look up the official pmndrs documentation before answering anything about react-three-fiber (R3F), drei, zustand, xr, uikit, react-postprocessing, a11y, leva, prai or viverse — hooks, components, props, signatures, store patterns, migrations. Use it whenever code or an answer depends on one of these APIs, rather than recalling the API from memory. +description: Look up the official pmndrs documentation before answering anything about react-three-fiber (R3F), drei or zustand — hooks, components, props, signatures, store patterns, migrations. Use it whenever code or an answer depends on one of these APIs, rather than recalling the API from memory. --- # pmndrs docs @@ -10,30 +10,30 @@ memory: these libraries move fast and recalled APIs go stale. ## Always index first -Never guess a page path — `get_page_content` only accepts paths that exist, and the -route shapes differ per library (`/api/hooks` for react-three-fiber, `/learn/guides/...` -for zustand, `/abstractions/...` for drei). Read the index resource, pick the path, -then fetch. +Never guess a page path — `get_page_content` matches exactly, and route shapes differ +per library (`/api/hooks` for react-three-fiber, `/learn/guides/...` and +`/reference/apis/...` for zustand, `/abstractions/...` for drei). Read the index +resource, pick the path, then fetch. 1. `ReadMcpResourceTool` on `docs://{lib}/index` — one `{path} - {title}` per line, 200–1,300 tokens. 2. `mcp__pmndrs__get_page_content` with `lib` and the exact `path` from step 1. -`lib` is one of: `react-three-fiber`, `drei`, `zustand`, `a11y`, -`react-postprocessing`, `uikit`, `xr`, `docs`, `prai`, `viverse`, `leva`. Names are -case-sensitive. +A bad path returns `MCP server error: Page not found: {path}` — re-read the index +instead of retrying variants. + +## Coverage + +Only four libraries are actually served: `react-three-fiber`, `drei`, `zustand`, and +`docs` (the generator itself). Names are case-sensitive. + +The tool's `lib` enum also advertises `a11y`, `react-postprocessing`, `uikit`, `xr`, +`prai`, `viverse` and `leva`, but their sites publish no `llms-full.txt`, so every +call fails and every index comes back empty. Don't route those through the MCP — use +WebFetch on their docs site. Fix in flight: pmndrs/docs#555. ## Budget Pages run 1,200–4,000 tokens each. Fetch the one page that answers the question. If three or more look relevant, read the index titles first and narrow — don't fetch the set and sort it out afterwards. - -## Known gaps - -- `docs://pmndrs/manifest` is partly stale: it cites jotai and valtio (not served) and - zustand paths under `/docs/...` that 404. Trust the per-library index, not the manifest. -- The `xr`, `uikit` and `leva` indexes currently return empty. Say so and fall back to - WebFetch on docs.pmnd.rs rather than guessing paths. -- A bad path returns `MCP server error: Page not found: {path}` — re-read the index - instead of retrying variants. From 7eaa861ebe17c75fecd12a8acf7564421a6ee41e Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 11 Aug 2026 09:45:48 +0200 Subject: [PATCH 03/10] Test the docs lookup against the live MCP server The skill states coverage as fact -- these four libraries are served, those seven are not -- and that claim is the part most likely to rot. It was already wrong once: the description shipped naming eleven libraries, seven of which return nothing. Nothing in the repo would have caught that. So the suite runs the workflow the skill prescribes, end to end: read docs://{lib}/index, take a path from it verbatim, fetch that page, assert real content comes back. Plus two drift detectors -- one that fails when an unserved library starts working, naming it and pointing at the SKILL.md section to widen, and one that fails if SKILL.md stops mentioning a library the tests cover. It talks to the real docs.pmnd.rs rather than a mock. A mock would keep passing through precisely the drift this exists to catch. CI runs it on push and weekly, since coverage can change with no commit here. No dependencies and no package.json: node:test and fetch are enough, and a plugin repo has no business carrying a lockfile for this. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 27 +++++++ README.md | 11 +++ test/docs-mcp.test.mjs | 148 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 test/docs-mcp.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a7ec46b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + # The docs MCP server can drift without a commit here, so check it on a schedule + # too -- a coverage change shows up as a red build rather than as Claude following + # a stale claim in skills/docs/SKILL.md. + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + # No dependencies: the suite runs on node:test and fetch alone + - run: node --test diff --git a/README.md b/README.md index fb893ac..44f49b9 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,17 @@ claude plugin validate . claude plugin validate .claude-plugin/plugin.json ``` +## Test + +``` +node --test +``` + +No dependencies — the suite runs on `node:test` and `fetch`. It talks to the real +`docs.pmnd.rs`, on purpose: what breaks is not the code here but the server's +coverage drifting away from what `skills/docs/SKILL.md` claims, and a mock would +keep passing through exactly that drift. CI also runs it weekly for the same reason. + ## License MIT diff --git a/test/docs-mcp.test.mjs b/test/docs-mcp.test.mjs new file mode 100644 index 0000000..3bd00d8 --- /dev/null +++ b/test/docs-mcp.test.mjs @@ -0,0 +1,148 @@ +// Live contract test for the `pmndrs` MCP server declared in .mcp.json. +// +// It talks to the real docs.pmnd.rs, on purpose. What can break here is not our +// code -- it is the server's coverage drifting away from what skills/docs/SKILL.md +// promises. A mock would keep passing through exactly that drift. +// +// node --test test/ + +import { test, describe, before } from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +// The libraries whose docs site publishes the /llms-full.txt dump the server parses. +// SKILL.md tells Claude these are the ones worth routing through MCP. +const SERVED = ['react-three-fiber', 'drei', 'zustand', 'docs'] + +// Advertised in the tool's `lib` enum, but their sites 404 on /llms-full.txt, so +// every call fails and every index comes back empty. See pmndrs/docs#555. +const UNSERVED = ['a11y', 'react-postprocessing', 'uikit', 'xr', 'prai', 'viverse', 'leva'] + +let endpoint +let skill + +async function rpc(method, params = {}) { + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(30_000), + }) + assert.ok(res.ok, `${method} -> HTTP ${res.status}`) + + // The server answers over SSE even for a single response + const body = await res.text() + const line = body.split('\n').find((l) => l.startsWith('data: ')) + assert.ok(line, `${method} -> no data frame in response: ${body.slice(0, 200)}`) + return JSON.parse(line.slice(6)) +} + +/** Index text for a library, or '' when the server has no dump to parse. */ +async function indexOf(lib) { + const { result, error } = await rpc('resources/read', { uri: `docs://${lib}/index` }) + if (error) return '' + return result.contents[0].text +} + +before(async () => { + const mcp = JSON.parse(await readFile(new URL('.mcp.json', `file://${root}`), 'utf8')) + endpoint = mcp.mcpServers.pmndrs.url + skill = await readFile(new URL('skills/docs/SKILL.md', `file://${root}`), 'utf8') +}) + +describe('.mcp.json', () => { + test('declares the pmndrs docs server over HTTP', () => { + assert.equal(endpoint, 'https://docs.pmnd.rs/api/mcp') + }) +}) + +describe('server contract', () => { + test('speaks MCP and identifies as pmndrs-docs', async () => { + const { result } = await rpc('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'pmndrs-plugin-test', version: '0' }, + }) + assert.equal(result.serverInfo.name, 'pmndrs-docs') + }) + + test('exposes get_page_content taking a lib and a path', async () => { + const { result } = await rpc('tools/list') + const tool = result.tools.find((t) => t.name === 'get_page_content') + + assert.ok(tool, `get_page_content is gone -- SKILL.md calls it by name`) + assert.deepEqual(tool.inputSchema.required.sort(), ['lib', 'path']) + }) + + test('exposes an index resource per library', async () => { + const { result } = await rpc('resources/list') + const uris = result.resources.map((r) => r.uri) + + for (const lib of SERVED) { + assert.ok(uris.includes(`docs://${lib}/index`), `missing docs://${lib}/index`) + } + }) +}) + +// The workflow SKILL.md prescribes: read the index, take a path from it verbatim, +// fetch that page. If this passes, documentation lookup genuinely works end to end. +describe('index-then-fetch', () => { + for (const lib of SERVED) { + test(`${lib} serves an index and its pages`, async () => { + const index = await indexOf(lib) + assert.ok(index.length > 0, `${lib} index is empty -- its llms-full.txt is missing`) + + const entries = index.split('\n').filter(Boolean) + for (const entry of entries) { + assert.match(entry, /^\/\S* - .+$/, `${lib} index line is not "{path} - {title}"`) + } + + const path = entries[0].split(' - ')[0] + const { result, error } = await rpc('tools/call', { + name: 'get_page_content', + arguments: { lib, path }, + }) + assert.ok(!error, `${lib} ${path} -> ${error?.message}`) + + const content = result.content[0].text + assert.ok(!content.startsWith('MCP server error'), `${lib} ${path} -> ${content}`) + assert.ok(content.length > 100, `${lib} ${path} -> ${content.length} chars, looks empty`) + }) + } +}) + +// Drift detectors. These fail when reality moves -- which is the point: SKILL.md +// states coverage as fact, and a stale claim sends Claude down a dead end. +describe('coverage claim', () => { + test('no unserved library has quietly started working', async () => { + const working = [] + for (const lib of UNSERVED) { + if ((await indexOf(lib)).length > 0) working.push(lib) + } + + assert.deepEqual( + working, + [], + `${working.join(', ')} now serve docs -- widen SERVED here and the Coverage ` + + `section of skills/docs/SKILL.md, which still tells Claude to use WebFetch for them`, + ) + }) + + test('SKILL.md names every served library', () => { + for (const lib of SERVED) { + assert.ok(skill.includes(lib), `SKILL.md never mentions ${lib}`) + } + }) + + test('SKILL.md warns about every unserved library', () => { + for (const lib of UNSERVED) { + assert.ok(skill.includes(lib), `SKILL.md never warns that ${lib} is unserved`) + } + }) +}) From db33c87ad0bd8580cc9224bb2ec5619a03fa85f7 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 11 Aug 2026 09:55:14 +0200 Subject: [PATCH 04/10] Keep the live server off the pull request path The previous suite hit docs.pmnd.rs on every PR. A patch touching nothing but a README would go red when a third party had a bad day, and a build that fails for reasons outside your change is a build people learn to ignore. Split by what a failure means. test/plugin.test.mjs is offline and gates PRs: .mcp.json shape, the skill frontmatter, the tool name matching the server key, and the Coverage section pinned to the lists in scripts/docs-coverage.mjs. Seven tests, 56ms, no sockets. scripts/check-docs-mcp.mjs keeps the live contract check and runs weekly, where a failure is signal -- the server moved and the skill now says something untrue -- and files an issue instead of reddening a build nobody connects to a change. It lives outside test/ because `node --test` walks that directory wholesale, regardless of the .test. suffix -- naming the file .live.mjs did not exclude it, as the run time gave away. Both suites are mutation-checked: declaring leva served, or moving xr into the skill's served list, each turns the offline suite red on the assertion that names the drift. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 7 +- .github/workflows/docs-mcp.yml | 48 +++++++++ README.md | 19 +++- .../check-docs-mcp.mjs | 77 +++------------ scripts/docs-coverage.mjs | 14 +++ skills/docs/SKILL.md | 12 +-- test/plugin.test.mjs | 99 +++++++++++++++++++ 7 files changed, 198 insertions(+), 78 deletions(-) create mode 100644 .github/workflows/docs-mcp.yml rename test/docs-mcp.test.mjs => scripts/check-docs-mcp.mjs (52%) create mode 100644 scripts/docs-coverage.mjs create mode 100644 test/plugin.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7ec46b..3b32d9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,6 @@ on: push: branches: [main] pull_request: - # The docs MCP server can drift without a commit here, so check it on a schedule - # too -- a coverage change shows up as a red build rather than as Claude following - # a stale claim in skills/docs/SKILL.md. - schedule: - - cron: '0 6 * * 1' workflow_dispatch: concurrency: @@ -23,5 +18,5 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 - # No dependencies: the suite runs on node:test and fetch alone + # No dependencies and no network: the suite runs on node:test alone - run: node --test diff --git a/.github/workflows/docs-mcp.yml b/.github/workflows/docs-mcp.yml new file mode 100644 index 0000000..e57fcde --- /dev/null +++ b/.github/workflows/docs-mcp.yml @@ -0,0 +1,48 @@ +name: docs-mcp + +# Checks the live docs.pmnd.rs MCP server against what skills/docs/SKILL.md claims. +# Never on pull_request: this can only fail for reasons outside the contributor's +# patch, and a build that goes red for someone else's outage is a build people learn +# to ignore. On a schedule, the same failure is signal -- the server's coverage moved +# and the skill now says something untrue. +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + + # shell: bash for pipefail -- the default shell is `bash -e` without it, so tee + # would swallow a failing exit status and the check would always look green + - shell: bash + run: node --test scripts/check-docs-mcp.mjs 2>&1 | tee check.log + + # A scheduled run nobody watches is worth nothing, so surface it where the work + # actually gets picked up. One issue, reopened and updated rather than piled up. + - if: failure() + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + body=$(printf 'The weekly check against `https://docs.pmnd.rs/api/mcp` failed.\n\nEither the server is down, or its coverage moved and `skills/docs/SKILL.md` now tells Claude something untrue. The assertion says which.\n\n```\n%s\n```\n\n[Full run](%s)\n' "$(tail -40 check.log)" "$RUN") + + # // empty, else jq prints the string "null" for an empty list and we would + # try to comment on issue null + existing=$(gh issue list --state open --label docs-mcp --limit 1 --json number -q '.[0].number // empty') + if [ -n "$existing" ]; then + gh issue comment "$existing" --body "$body" + else + gh label create docs-mcp --description "Live docs MCP server check" --color FBCA04 --force + gh issue create --title "docs MCP server check is failing" --label docs-mcp --body "$body" + fi diff --git a/README.md b/README.md index 44f49b9..08c9262 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,21 @@ claude plugin validate .claude-plugin/plugin.json node --test ``` -No dependencies — the suite runs on `node:test` and `fetch`. It talks to the real -`docs.pmnd.rs`, on purpose: what breaks is not the code here but the server's -coverage drifting away from what `skills/docs/SKILL.md` claims, and a mock would -keep passing through exactly that drift. CI also runs it weekly for the same reason. +Offline checks over this repo's own files — no dependencies, no network, runs in +milliseconds. This is what CI gates pull requests on. + +``` +node --test scripts/check-docs-mcp.mjs +``` + +The live check against `https://docs.pmnd.rs/api/mcp`. It runs weekly rather than on +pull requests: it can only fail for reasons outside your patch, and it files an issue +when it does. What it catches is the server's coverage drifting away from what +`skills/docs/SKILL.md` claims — so it talks to the real server, since a mock would +keep passing through exactly that drift. + +The claim itself lives in [`scripts/docs-coverage.mjs`](scripts/docs-coverage.mjs): +the offline suite pins the skill's prose to it, the live one pins it to reality. ## License diff --git a/test/docs-mcp.test.mjs b/scripts/check-docs-mcp.mjs similarity index 52% rename from test/docs-mcp.test.mjs rename to scripts/check-docs-mcp.mjs index 3bd00d8..84c52df 100644 --- a/test/docs-mcp.test.mjs +++ b/scripts/check-docs-mcp.mjs @@ -1,31 +1,18 @@ -// Live contract test for the `pmndrs` MCP server declared in .mcp.json. +// Live contract check against the real docs.pmnd.rs MCP server. // -// It talks to the real docs.pmnd.rs, on purpose. What can break here is not our -// code -- it is the server's coverage drifting away from what skills/docs/SKILL.md -// promises. A mock would keep passing through exactly that drift. +// Deliberately outside test/, which `node --test` walks wholesale regardless of file +// naming: a third party being down must never redden a pull request here. CI runs +// this on a schedule instead, where a failure means the world moved rather than that +// someone's patch is wrong. // -// node --test test/ +// node --test scripts/check-docs-mcp.mjs -import { test, describe, before } from 'node:test' +import { test, describe } from 'node:test' import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' - -const root = fileURLToPath(new URL('..', import.meta.url)) - -// The libraries whose docs site publishes the /llms-full.txt dump the server parses. -// SKILL.md tells Claude these are the ones worth routing through MCP. -const SERVED = ['react-three-fiber', 'drei', 'zustand', 'docs'] - -// Advertised in the tool's `lib` enum, but their sites 404 on /llms-full.txt, so -// every call fails and every index comes back empty. See pmndrs/docs#555. -const UNSERVED = ['a11y', 'react-postprocessing', 'uikit', 'xr', 'prai', 'viverse', 'leva'] - -let endpoint -let skill +import { ENDPOINT, SERVED, UNSERVED } from './docs-coverage.mjs' async function rpc(method, params = {}) { - const res = await fetch(endpoint, { + const res = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -46,22 +33,9 @@ async function rpc(method, params = {}) { /** Index text for a library, or '' when the server has no dump to parse. */ async function indexOf(lib) { const { result, error } = await rpc('resources/read', { uri: `docs://${lib}/index` }) - if (error) return '' - return result.contents[0].text + return error ? '' : result.contents[0].text } -before(async () => { - const mcp = JSON.parse(await readFile(new URL('.mcp.json', `file://${root}`), 'utf8')) - endpoint = mcp.mcpServers.pmndrs.url - skill = await readFile(new URL('skills/docs/SKILL.md', `file://${root}`), 'utf8') -}) - -describe('.mcp.json', () => { - test('declares the pmndrs docs server over HTTP', () => { - assert.equal(endpoint, 'https://docs.pmnd.rs/api/mcp') - }) -}) - describe('server contract', () => { test('speaks MCP and identifies as pmndrs-docs', async () => { const { result } = await rpc('initialize', { @@ -69,6 +43,7 @@ describe('server contract', () => { capabilities: {}, clientInfo: { name: 'pmndrs-plugin-test', version: '0' }, }) + assert.equal(result.serverInfo.name, 'pmndrs-docs') }) @@ -76,18 +51,9 @@ describe('server contract', () => { const { result } = await rpc('tools/list') const tool = result.tools.find((t) => t.name === 'get_page_content') - assert.ok(tool, `get_page_content is gone -- SKILL.md calls it by name`) + assert.ok(tool, 'get_page_content is gone -- SKILL.md calls it by name') assert.deepEqual(tool.inputSchema.required.sort(), ['lib', 'path']) }) - - test('exposes an index resource per library', async () => { - const { result } = await rpc('resources/list') - const uris = result.resources.map((r) => r.uri) - - for (const lib of SERVED) { - assert.ok(uris.includes(`docs://${lib}/index`), `missing docs://${lib}/index`) - } - }) }) // The workflow SKILL.md prescribes: read the index, take a path from it verbatim, @@ -117,8 +83,6 @@ describe('index-then-fetch', () => { } }) -// Drift detectors. These fail when reality moves -- which is the point: SKILL.md -// states coverage as fact, and a stale claim sends Claude down a dead end. describe('coverage claim', () => { test('no unserved library has quietly started working', async () => { const working = [] @@ -129,20 +93,9 @@ describe('coverage claim', () => { assert.deepEqual( working, [], - `${working.join(', ')} now serve docs -- widen SERVED here and the Coverage ` + - `section of skills/docs/SKILL.md, which still tells Claude to use WebFetch for them`, + `${working.join(', ')} now serve docs -- move them to SERVED in ` + + `scripts/docs-coverage.mjs and update the Coverage section of ` + + `skills/docs/SKILL.md, which still sends Claude to WebFetch for them`, ) }) - - test('SKILL.md names every served library', () => { - for (const lib of SERVED) { - assert.ok(skill.includes(lib), `SKILL.md never mentions ${lib}`) - } - }) - - test('SKILL.md warns about every unserved library', () => { - for (const lib of UNSERVED) { - assert.ok(skill.includes(lib), `SKILL.md never warns that ${lib} is unserved`) - } - }) }) diff --git a/scripts/docs-coverage.mjs b/scripts/docs-coverage.mjs new file mode 100644 index 0000000..948a13a --- /dev/null +++ b/scripts/docs-coverage.mjs @@ -0,0 +1,14 @@ +// What skills/docs/SKILL.md claims about the docs MCP server, as data. +// +// Kept here so the two suites can disagree with each other: test/plugin.test.mjs +// checks the skill's prose against these lists offline, check-docs-mcp.mjs checks the +// lists against the real server on a schedule. + +export const ENDPOINT = 'https://docs.pmnd.rs/api/mcp' + +// Libraries whose docs site publishes the /llms-full.txt dump the server parses. +export const SERVED = ['react-three-fiber', 'drei', 'zustand', 'docs'] + +// Advertised in the tool's `lib` enum, but their sites 404 on /llms-full.txt, so +// every call fails and every index comes back empty. Fix in flight: pmndrs/docs#555. +export const UNSERVED = ['a11y', 'react-postprocessing', 'uikit', 'xr', 'prai', 'viverse', 'leva'] diff --git a/skills/docs/SKILL.md b/skills/docs/SKILL.md index 28685d7..1787811 100644 --- a/skills/docs/SKILL.md +++ b/skills/docs/SKILL.md @@ -24,13 +24,13 @@ instead of retrying variants. ## Coverage -Only four libraries are actually served: `react-three-fiber`, `drei`, `zustand`, and -`docs` (the generator itself). Names are case-sensitive. +Served — route these through the MCP: `react-three-fiber`, `drei`, `zustand`, `docs` +(the generator itself). Names are case-sensitive. -The tool's `lib` enum also advertises `a11y`, `react-postprocessing`, `uikit`, `xr`, -`prai`, `viverse` and `leva`, but their sites publish no `llms-full.txt`, so every -call fails and every index comes back empty. Don't route those through the MCP — use -WebFetch on their docs site. Fix in flight: pmndrs/docs#555. +Unserved — the tool's `lib` enum also advertises `a11y`, `react-postprocessing`, +`uikit`, `xr`, `prai`, `viverse`, `leva`, but their sites publish no `llms-full.txt`, +so every call fails and every index comes back empty. Use WebFetch on their docs site +instead. Fix in flight: pmndrs/docs#555. ## Budget diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs new file mode 100644 index 0000000..2e1f847 --- /dev/null +++ b/test/plugin.test.mjs @@ -0,0 +1,99 @@ +// Offline checks over the plugin's own files. No network: a PR here must never go +// red because a third party is having a bad day. The live server is checked +// separately, on a schedule, by scripts/check-docs-mcp.mjs. +// +// node --test + +import { test, describe } from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { ENDPOINT, SERVED, UNSERVED } from '../scripts/docs-coverage.mjs' + +const read = (p) => readFile(new URL(`../${p}`, import.meta.url), 'utf8') +const readJson = async (p) => JSON.parse(await read(p)) + +describe('.mcp.json', () => { + test('declares the pmndrs docs server over HTTP', async () => { + const { mcpServers } = await readJson('.mcp.json') + + assert.deepEqual(Object.keys(mcpServers), ['pmndrs']) + assert.equal(mcpServers.pmndrs.type, 'http') + // The server key is what namespaces the tools, so SKILL.md can only call them + // mcp__pmndrs__* while this stays 'pmndrs' + assert.equal(mcpServers.pmndrs.url, ENDPOINT) + }) +}) + +describe('skills/docs', () => { + test('has the frontmatter Claude needs to trigger it', async () => { + const skill = await read('skills/docs/SKILL.md') + const [, frontmatter] = skill.split('---\n', 2) + + assert.match(frontmatter, /^name: docs$/m) + assert.match(frontmatter, /^description: \S/m) + }) + + test('calls the MCP tool by its namespaced name', async () => { + const skill = await read('skills/docs/SKILL.md') + + assert.ok( + skill.includes('mcp__pmndrs__get_page_content'), + 'the tool name must match the .mcp.json server key', + ) + }) + + // The Coverage section states which libraries are worth routing through the MCP. + // Prose drifts; these keep it pinned to coverage.mjs, which the live suite checks + // against the real server. + describe('Coverage section', () => { + const paragraphs = async () => { + const skill = await read('skills/docs/SKILL.md') + const section = skill.split('## Coverage')[1]?.split('\n## ')[0] + assert.ok(section, 'SKILL.md lost its "## Coverage" section') + + const served = section.split(/\n\s*\n/).find((p) => p.trim().startsWith('Served')) + const unserved = section.split(/\n\s*\n/).find((p) => p.trim().startsWith('Unserved')) + assert.ok(served, 'Coverage needs a paragraph starting with "Served"') + assert.ok(unserved, 'Coverage needs a paragraph starting with "Unserved"') + + return { served, unserved } + } + + test('lists every served library as served', async () => { + const { served } = await paragraphs() + + for (const lib of SERVED) { + assert.ok(served.includes(`\`${lib}\``), `Coverage never lists ${lib} as served`) + } + }) + + test('lists every unserved library as unserved', async () => { + const { unserved } = await paragraphs() + + for (const lib of UNSERVED) { + assert.ok(unserved.includes(`\`${lib}\``), `Coverage never warns that ${lib} is unserved`) + } + }) + + test('does not send Claude to a library that answers nothing', async () => { + const { served } = await paragraphs() + + for (const lib of UNSERVED) { + assert.ok(!served.includes(`\`${lib}\``), `Coverage lists ${lib} as served, but it is not`) + } + }) + }) +}) + +describe('manifests', () => { + test('plugin.json names the plugin the marketplace publishes', async () => { + const plugin = await readJson('.claude-plugin/plugin.json') + const marketplace = await readJson('.claude-plugin/marketplace.json') + + assert.equal(plugin.name, 'pmndrs') + assert.ok( + marketplace.plugins.some((p) => p.name === plugin.name), + `marketplace.json publishes no plugin named ${plugin.name}`, + ) + }) +}) From 788b0eae321c0b88c6461042bf2067f8b848158b Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 11 Aug 2026 09:56:17 +0200 Subject: [PATCH 05/10] Bump the CI actions off the deprecated Node 20 runtime checkout@v4 and setup-node@v4 target Node 20, which the runners now force onto 24 with a deprecation annotation on every run. Annotations that show up unconditionally stop being read. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 4 ++-- .github/workflows/docs-mcp.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b32d9d..a69610c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,8 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 24 # No dependencies and no network: the suite runs on node:test alone diff --git a/.github/workflows/docs-mcp.yml b/.github/workflows/docs-mcp.yml index e57fcde..55e3c80 100644 --- a/.github/workflows/docs-mcp.yml +++ b/.github/workflows/docs-mcp.yml @@ -16,8 +16,8 @@ jobs: permissions: issues: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 24 From aad59f7d00e329125ae82b0804e52d9f252999b1 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 11 Aug 2026 10:20:17 +0200 Subject: [PATCH 06/10] Derive the unserved libraries instead of listing them UNSERVED was a hand-maintained mirror of SERVED, and it only covered the seven libraries that happened to be broken the day it was written. A library that started working was caught; one the server newly advertised was not, and neither was the pair silently disagreeing. The server publishes its own universe in the get_page_content `lib` enum, so the check now reads it, probes every entry, and asserts that the set which actually works equals SERVED. One list to maintain, and it fails in both directions -- a library that starts working reads as loudly as one that stops. The skill stops enumerating them too, for the same reason: it states the rule ("anything not listed above is unserved, use WebFetch") rather than seven names that rot the day pmndrs/docs#555 lands. The offline suite pins its served list to SERVED by set equality, so a name added to the prose alone is the same failure as one dropped. Moves to vitest and .mts along the way. The suites split by config rather than by directory -- vitest globs are explicit, so the .live.mts suffix genuinely excludes the network check, which is what `node --test` walking all of test/ would not do. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 8 +- .github/workflows/docs-mcp.yml | 6 +- README.md | 25 +- package-lock.json | 1203 ++++++++++++++++++++++++++++++++ package.json | 15 + scripts/check-docs-mcp.mjs | 101 --- scripts/docs-coverage.mjs | 14 - skills/docs/SKILL.md | 8 +- test/coverage.mts | 10 + test/docs-mcp.live.mts | 105 +++ test/plugin.test.mjs | 99 --- test/plugin.test.mts | 92 +++ tsconfig.json | 14 + vitest.config.mts | 9 + vitest.live.config.mts | 15 + 15 files changed, 1491 insertions(+), 233 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json delete mode 100644 scripts/check-docs-mcp.mjs delete mode 100644 scripts/docs-coverage.mjs create mode 100644 test/coverage.mts create mode 100644 test/docs-mcp.live.mts delete mode 100644 test/plugin.test.mjs create mode 100644 test/plugin.test.mts create mode 100644 tsconfig.json create mode 100644 vitest.config.mts create mode 100644 vitest.live.config.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a69610c..ee04fe1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,5 +18,9 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 24 - # No dependencies and no network: the suite runs on node:test alone - - run: node --test + cache: npm + - run: npm ci + - run: npm run typecheck + # Offline by construction: vitest.config.mts matches test/**/*.test.mts only, + # which leaves the live check out + - run: npm test diff --git a/.github/workflows/docs-mcp.yml b/.github/workflows/docs-mcp.yml index 55e3c80..0a10f9a 100644 --- a/.github/workflows/docs-mcp.yml +++ b/.github/workflows/docs-mcp.yml @@ -20,11 +20,13 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 24 + cache: npm + - run: npm ci # shell: bash for pipefail -- the default shell is `bash -e` without it, so tee # would swallow a failing exit status and the check would always look green - shell: bash - run: node --test scripts/check-docs-mcp.mjs 2>&1 | tee check.log + run: npm run test:live 2>&1 | tee check.log # A scheduled run nobody watches is worth nothing, so surface it where the work # actually gets picked up. One issue, reopened and updated rather than piled up. @@ -35,7 +37,7 @@ jobs: RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail - body=$(printf 'The weekly check against `https://docs.pmnd.rs/api/mcp` failed.\n\nEither the server is down, or its coverage moved and `skills/docs/SKILL.md` now tells Claude something untrue. The assertion says which.\n\n```\n%s\n```\n\n[Full run](%s)\n' "$(tail -40 check.log)" "$RUN") + body=$(printf 'The weekly check against `https://docs.pmnd.rs/api/mcp` failed.\n\nEither the server is down, or its coverage moved and `skills/docs/SKILL.md` now tells Claude something untrue — in which case update `SERVED` in `test/coverage.mts` and the Coverage section of the skill. The assertion says which.\n\n```\n%s\n```\n\n[Full run](%s)\n' "$(tail -40 check.log)" "$RUN") # // empty, else jq prints the string "null" for an empty list and we would # try to comment on issue null diff --git a/README.md b/README.md index 08c9262..4843740 100644 --- a/README.md +++ b/README.md @@ -45,24 +45,27 @@ claude plugin validate .claude-plugin/plugin.json ## Test ``` -node --test +npm test ``` -Offline checks over this repo's own files — no dependencies, no network, runs in -milliseconds. This is what CI gates pull requests on. +Offline checks over this repo's own files. `vitest.config.mts` matches +`test/**/*.test.mts` only, so this can never reach the network. It is what CI gates +pull requests on, alongside `npm run typecheck`. ``` -node --test scripts/check-docs-mcp.mjs +npm run test:live ``` -The live check against `https://docs.pmnd.rs/api/mcp`. It runs weekly rather than on -pull requests: it can only fail for reasons outside your patch, and it files an issue -when it does. What it catches is the server's coverage drifting away from what -`skills/docs/SKILL.md` claims — so it talks to the real server, since a mock would -keep passing through exactly that drift. +The live check against `https://docs.pmnd.rs/api/mcp`, on its own config so `npm test` +never picks it up. It runs weekly rather than on pull requests: it can only fail for +reasons outside your patch, and it files an issue when it does. What it catches is the +server's coverage drifting away from what `skills/docs/SKILL.md` claims — so it talks +to the real server, since a mock would keep passing through exactly that drift. -The claim itself lives in [`scripts/docs-coverage.mjs`](scripts/docs-coverage.mjs): -the offline suite pins the skill's prose to it, the live one pins it to reality. +Only the positive claim is maintained, in [`test/coverage.mts`](test/coverage.mts). +The complement is derived from the `lib` enum the server publishes, so a library that +starts working — or a new one appearing in the enum — fails the check without anyone +keeping a second list in sync. ## License diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..73100df --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1203 @@ +{ + "name": "@pmndrs/claude-code-plugin", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@pmndrs/claude-code-plugin", + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c0cc427 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "@pmndrs/claude-code-plugin", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:live": "vitest run --config vitest.live.config.mts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/scripts/check-docs-mcp.mjs b/scripts/check-docs-mcp.mjs deleted file mode 100644 index 84c52df..0000000 --- a/scripts/check-docs-mcp.mjs +++ /dev/null @@ -1,101 +0,0 @@ -// Live contract check against the real docs.pmnd.rs MCP server. -// -// Deliberately outside test/, which `node --test` walks wholesale regardless of file -// naming: a third party being down must never redden a pull request here. CI runs -// this on a schedule instead, where a failure means the world moved rather than that -// someone's patch is wrong. -// -// node --test scripts/check-docs-mcp.mjs - -import { test, describe } from 'node:test' -import assert from 'node:assert/strict' -import { ENDPOINT, SERVED, UNSERVED } from './docs-coverage.mjs' - -async function rpc(method, params = {}) { - const res = await fetch(ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - signal: AbortSignal.timeout(30_000), - }) - assert.ok(res.ok, `${method} -> HTTP ${res.status}`) - - // The server answers over SSE even for a single response - const body = await res.text() - const line = body.split('\n').find((l) => l.startsWith('data: ')) - assert.ok(line, `${method} -> no data frame in response: ${body.slice(0, 200)}`) - return JSON.parse(line.slice(6)) -} - -/** Index text for a library, or '' when the server has no dump to parse. */ -async function indexOf(lib) { - const { result, error } = await rpc('resources/read', { uri: `docs://${lib}/index` }) - return error ? '' : result.contents[0].text -} - -describe('server contract', () => { - test('speaks MCP and identifies as pmndrs-docs', async () => { - const { result } = await rpc('initialize', { - protocolVersion: '2025-06-18', - capabilities: {}, - clientInfo: { name: 'pmndrs-plugin-test', version: '0' }, - }) - - assert.equal(result.serverInfo.name, 'pmndrs-docs') - }) - - test('exposes get_page_content taking a lib and a path', async () => { - const { result } = await rpc('tools/list') - const tool = result.tools.find((t) => t.name === 'get_page_content') - - assert.ok(tool, 'get_page_content is gone -- SKILL.md calls it by name') - assert.deepEqual(tool.inputSchema.required.sort(), ['lib', 'path']) - }) -}) - -// The workflow SKILL.md prescribes: read the index, take a path from it verbatim, -// fetch that page. If this passes, documentation lookup genuinely works end to end. -describe('index-then-fetch', () => { - for (const lib of SERVED) { - test(`${lib} serves an index and its pages`, async () => { - const index = await indexOf(lib) - assert.ok(index.length > 0, `${lib} index is empty -- its llms-full.txt is missing`) - - const entries = index.split('\n').filter(Boolean) - for (const entry of entries) { - assert.match(entry, /^\/\S* - .+$/, `${lib} index line is not "{path} - {title}"`) - } - - const path = entries[0].split(' - ')[0] - const { result, error } = await rpc('tools/call', { - name: 'get_page_content', - arguments: { lib, path }, - }) - assert.ok(!error, `${lib} ${path} -> ${error?.message}`) - - const content = result.content[0].text - assert.ok(!content.startsWith('MCP server error'), `${lib} ${path} -> ${content}`) - assert.ok(content.length > 100, `${lib} ${path} -> ${content.length} chars, looks empty`) - }) - } -}) - -describe('coverage claim', () => { - test('no unserved library has quietly started working', async () => { - const working = [] - for (const lib of UNSERVED) { - if ((await indexOf(lib)).length > 0) working.push(lib) - } - - assert.deepEqual( - working, - [], - `${working.join(', ')} now serve docs -- move them to SERVED in ` + - `scripts/docs-coverage.mjs and update the Coverage section of ` + - `skills/docs/SKILL.md, which still sends Claude to WebFetch for them`, - ) - }) -}) diff --git a/scripts/docs-coverage.mjs b/scripts/docs-coverage.mjs deleted file mode 100644 index 948a13a..0000000 --- a/scripts/docs-coverage.mjs +++ /dev/null @@ -1,14 +0,0 @@ -// What skills/docs/SKILL.md claims about the docs MCP server, as data. -// -// Kept here so the two suites can disagree with each other: test/plugin.test.mjs -// checks the skill's prose against these lists offline, check-docs-mcp.mjs checks the -// lists against the real server on a schedule. - -export const ENDPOINT = 'https://docs.pmnd.rs/api/mcp' - -// Libraries whose docs site publishes the /llms-full.txt dump the server parses. -export const SERVED = ['react-three-fiber', 'drei', 'zustand', 'docs'] - -// Advertised in the tool's `lib` enum, but their sites 404 on /llms-full.txt, so -// every call fails and every index comes back empty. Fix in flight: pmndrs/docs#555. -export const UNSERVED = ['a11y', 'react-postprocessing', 'uikit', 'xr', 'prai', 'viverse', 'leva'] diff --git a/skills/docs/SKILL.md b/skills/docs/SKILL.md index 1787811..9b33a6d 100644 --- a/skills/docs/SKILL.md +++ b/skills/docs/SKILL.md @@ -27,10 +27,10 @@ instead of retrying variants. Served — route these through the MCP: `react-three-fiber`, `drei`, `zustand`, `docs` (the generator itself). Names are case-sensitive. -Unserved — the tool's `lib` enum also advertises `a11y`, `react-postprocessing`, -`uikit`, `xr`, `prai`, `viverse`, `leva`, but their sites publish no `llms-full.txt`, -so every call fails and every index comes back empty. Use WebFetch on their docs site -instead. Fix in flight: pmndrs/docs#555. +Anything else is unserved. The tool's `lib` enum advertises more libraries than that, +but the extra entries have no `llms-full.txt` behind them: every call fails and every +index comes back empty. Treat a library missing from the list above as absent, and use +WebFetch on its docs site. Fix in flight: pmndrs/docs#555. ## Budget diff --git a/test/coverage.mts b/test/coverage.mts new file mode 100644 index 0000000..6418bf8 --- /dev/null +++ b/test/coverage.mts @@ -0,0 +1,10 @@ +// What skills/docs/SKILL.md claims about the docs MCP server, as data. +// +// Only the positive claim is written down. The complement is derived from the `lib` +// enum the server publishes, so a library that starts working -- or a new one +// appearing in the enum -- is caught without anyone maintaining a second list. + +export const ENDPOINT = 'https://docs.pmnd.rs/api/mcp' + +/** Libraries whose docs site publishes the /llms-full.txt dump the server parses. */ +export const SERVED = ['react-three-fiber', 'drei', 'zustand', 'docs'] diff --git a/test/docs-mcp.live.mts b/test/docs-mcp.live.mts new file mode 100644 index 0000000..9b916ec --- /dev/null +++ b/test/docs-mcp.live.mts @@ -0,0 +1,105 @@ +// Live contract check against the real docs.pmnd.rs MCP server. +// +// npm run test:live +// +// Not matched by vitest.config.mts, so `npm test` never reaches the network. CI runs +// this weekly, where a failure means the world moved rather than that someone's patch +// is wrong. + +import { describe, expect, test } from 'vitest' +import { ENDPOINT, SERVED } from './coverage.mts' + +async function rpc(method: string, params: Record = {}) { + const res = await fetch(ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(30_000), + }) + expect(res.ok, `${method} -> HTTP ${res.status}`).toBe(true) + + // The server answers over SSE even for a single response + const body = await res.text() + const line = body.split('\n').find((l) => l.startsWith('data: ')) + expect(line, `${method} -> no data frame in: ${body.slice(0, 200)}`).toBeDefined() + return JSON.parse(line!.slice(6)) +} + +/** Every library the server advertises -- its own enum is the authority, not us. */ +async function advertised(): Promise { + const { result } = await rpc('tools/list') + const tool = result.tools.find((t: { name: string }) => t.name === 'get_page_content') + expect(tool, 'get_page_content is gone -- SKILL.md calls it by name').toBeDefined() + return tool.inputSchema.properties.lib.enum +} + +/** Index text for a library, or '' when the server has no dump to parse. */ +async function indexOf(lib: string): Promise { + const { result, error } = await rpc('resources/read', { uri: `docs://${lib}/index` }) + return error ? '' : result.contents[0].text +} + +describe('server contract', () => { + test('speaks MCP and identifies as pmndrs-docs', async () => { + const { result } = await rpc('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'pmndrs-plugin-test', version: '0' }, + }) + + expect(result.serverInfo.name).toBe('pmndrs-docs') + }) + + test('exposes get_page_content taking a lib and a path', async () => { + const { result } = await rpc('tools/list') + const tool = result.tools.find((t: { name: string }) => t.name === 'get_page_content') + + expect(tool.inputSchema.required.toSorted()).toEqual(['lib', 'path']) + }) +}) + +// The workflow SKILL.md prescribes: read the index, take a path from it verbatim, +// fetch that page. If this passes, documentation lookup genuinely works end to end. +describe('index-then-fetch', () => { + test.for(SERVED)('%s serves an index and its pages', async (lib) => { + const index = await indexOf(lib) + expect(index, `${lib} index is empty -- its llms-full.txt is missing`).not.toBe('') + + const entries = index.split('\n').filter(Boolean) + for (const entry of entries) { + expect(entry, `${lib} index line is not "{path} - {title}"`).toMatch(/^\/\S* - .+$/) + } + + const path = entries[0]!.split(' - ')[0]! + const { result, error } = await rpc('tools/call', { + name: 'get_page_content', + arguments: { lib, path }, + }) + expect(error, `${lib} ${path} -> ${error?.message}`).toBeUndefined() + + const content: string = result.content[0].text + expect(content, `${lib} ${path} -> ${content}`).not.toMatch(/^MCP server error/) + expect(content.length, `${lib} ${path} -> looks empty`).toBeGreaterThan(100) + }) +}) + +describe('coverage claim', () => { + // The one assertion that keeps SERVED honest in both directions, over whatever set + // of libraries the server happens to advertise today. A library that starts working + // fails this just as loudly as one that stops. + test('the libraries that actually work are exactly the ones we claim', async () => { + const libs = await advertised() + const working: string[] = [] + for (const lib of libs) { + if ((await indexOf(lib)) !== '') working.push(lib) + } + + expect( + working.toSorted(), + `update SERVED in test/coverage.mts and the Coverage section of skills/docs/SKILL.md`, + ).toEqual(SERVED.toSorted()) + }) +}) diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs deleted file mode 100644 index 2e1f847..0000000 --- a/test/plugin.test.mjs +++ /dev/null @@ -1,99 +0,0 @@ -// Offline checks over the plugin's own files. No network: a PR here must never go -// red because a third party is having a bad day. The live server is checked -// separately, on a schedule, by scripts/check-docs-mcp.mjs. -// -// node --test - -import { test, describe } from 'node:test' -import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' -import { ENDPOINT, SERVED, UNSERVED } from '../scripts/docs-coverage.mjs' - -const read = (p) => readFile(new URL(`../${p}`, import.meta.url), 'utf8') -const readJson = async (p) => JSON.parse(await read(p)) - -describe('.mcp.json', () => { - test('declares the pmndrs docs server over HTTP', async () => { - const { mcpServers } = await readJson('.mcp.json') - - assert.deepEqual(Object.keys(mcpServers), ['pmndrs']) - assert.equal(mcpServers.pmndrs.type, 'http') - // The server key is what namespaces the tools, so SKILL.md can only call them - // mcp__pmndrs__* while this stays 'pmndrs' - assert.equal(mcpServers.pmndrs.url, ENDPOINT) - }) -}) - -describe('skills/docs', () => { - test('has the frontmatter Claude needs to trigger it', async () => { - const skill = await read('skills/docs/SKILL.md') - const [, frontmatter] = skill.split('---\n', 2) - - assert.match(frontmatter, /^name: docs$/m) - assert.match(frontmatter, /^description: \S/m) - }) - - test('calls the MCP tool by its namespaced name', async () => { - const skill = await read('skills/docs/SKILL.md') - - assert.ok( - skill.includes('mcp__pmndrs__get_page_content'), - 'the tool name must match the .mcp.json server key', - ) - }) - - // The Coverage section states which libraries are worth routing through the MCP. - // Prose drifts; these keep it pinned to coverage.mjs, which the live suite checks - // against the real server. - describe('Coverage section', () => { - const paragraphs = async () => { - const skill = await read('skills/docs/SKILL.md') - const section = skill.split('## Coverage')[1]?.split('\n## ')[0] - assert.ok(section, 'SKILL.md lost its "## Coverage" section') - - const served = section.split(/\n\s*\n/).find((p) => p.trim().startsWith('Served')) - const unserved = section.split(/\n\s*\n/).find((p) => p.trim().startsWith('Unserved')) - assert.ok(served, 'Coverage needs a paragraph starting with "Served"') - assert.ok(unserved, 'Coverage needs a paragraph starting with "Unserved"') - - return { served, unserved } - } - - test('lists every served library as served', async () => { - const { served } = await paragraphs() - - for (const lib of SERVED) { - assert.ok(served.includes(`\`${lib}\``), `Coverage never lists ${lib} as served`) - } - }) - - test('lists every unserved library as unserved', async () => { - const { unserved } = await paragraphs() - - for (const lib of UNSERVED) { - assert.ok(unserved.includes(`\`${lib}\``), `Coverage never warns that ${lib} is unserved`) - } - }) - - test('does not send Claude to a library that answers nothing', async () => { - const { served } = await paragraphs() - - for (const lib of UNSERVED) { - assert.ok(!served.includes(`\`${lib}\``), `Coverage lists ${lib} as served, but it is not`) - } - }) - }) -}) - -describe('manifests', () => { - test('plugin.json names the plugin the marketplace publishes', async () => { - const plugin = await readJson('.claude-plugin/plugin.json') - const marketplace = await readJson('.claude-plugin/marketplace.json') - - assert.equal(plugin.name, 'pmndrs') - assert.ok( - marketplace.plugins.some((p) => p.name === plugin.name), - `marketplace.json publishes no plugin named ${plugin.name}`, - ) - }) -}) diff --git a/test/plugin.test.mts b/test/plugin.test.mts new file mode 100644 index 0000000..7a6d71e --- /dev/null +++ b/test/plugin.test.mts @@ -0,0 +1,92 @@ +// Offline checks over the plugin's own files. No network: a PR here must never go red +// because a third party is having a bad day. The live server is checked separately, +// on a schedule, by docs-mcp.live.mts. +// +// npm test + +import { readFile } from 'node:fs/promises' +import { describe, expect, test } from 'vitest' +import { ENDPOINT, SERVED } from './coverage.mts' + +const read = (p: string) => readFile(new URL(`../${p}`, import.meta.url), 'utf8') +const readJson = async (p: string) => JSON.parse(await read(p)) + +const skill = () => read('skills/docs/SKILL.md') + +describe('.mcp.json', () => { + test('declares the pmndrs docs server over HTTP', async () => { + const { mcpServers } = await readJson('.mcp.json') + + expect(Object.keys(mcpServers)).toEqual(['pmndrs']) + expect(mcpServers.pmndrs.type).toBe('http') + // The server key is what namespaces the tools, so SKILL.md can only call them + // mcp__pmndrs__* while this stays 'pmndrs' + expect(mcpServers.pmndrs.url).toBe(ENDPOINT) + }) +}) + +describe('skills/docs', () => { + test('has the frontmatter Claude needs to trigger it', async () => { + const [, frontmatter] = (await skill()).split('---\n', 2) + + expect(frontmatter).toMatch(/^name: docs$/m) + expect(frontmatter).toMatch(/^description: \S/m) + }) + + test('calls the MCP tool by its namespaced name', async () => { + expect( + await skill(), + 'the tool name must match the .mcp.json server key', + ).toContain('mcp__pmndrs__get_page_content') + }) + + // The Coverage section states which libraries are worth routing through the MCP. + // Prose drifts; this keeps it pinned to coverage.mts, which the live check pins to + // the real server. + describe('Coverage section', () => { + const coverage = async () => { + const section = (await skill()).split('## Coverage')[1]?.split('\n## ')[0] + expect(section, 'SKILL.md lost its "## Coverage" section').toBeDefined() + + const paragraphs = section!.split(/\n\s*\n/).filter((p) => p.trim()) + const served = paragraphs.find((p) => p.trim().startsWith('Served')) + expect(served, 'Coverage needs a paragraph starting with "Served"').toBeDefined() + + return { section: section!, served: served! } + } + + test('lists exactly the served libraries, no more and no fewer', async () => { + const { served } = await coverage() + const named = [...served.matchAll(/`([^`]+)`/g)].map((m) => m[1]!) + + // Set equality, not "includes": a library quietly added to the prose without + // being added to SERVED is the same bug as one silently dropped + expect(named.toSorted()).toEqual(SERVED.toSorted()) + }) + + // Deliberately not asserted: that the rest of the section names no library. Prose + // can enumerate them a dozen ways and a regex would only pretend to catch it. The + // load-bearing guarantee is the set equality above -- as long as the served list + // is exact, a stale name elsewhere misleads about nothing Claude acts on. + test('points everything else at WebFetch', async () => { + const { section, served } = await coverage() + const rest = section.replace(served, '') + + expect(rest).toMatch(/unserved/i) + expect(rest).toContain('WebFetch') + }) + }) +}) + +describe('manifests', () => { + test('plugin.json names the plugin the marketplace publishes', async () => { + const plugin = await readJson('.claude-plugin/plugin.json') + const marketplace = await readJson('.claude-plugin/marketplace.json') + + expect(plugin.name).toBe('pmndrs') + expect( + marketplace.plugins.map((p: { name: string }) => p.name), + `marketplace.json publishes no plugin named ${plugin.name}`, + ).toContain(plugin.name) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d939f52 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["test", "*.mts"] +} diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..f67d96c --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +// Offline checks only. *.live.mts is deliberately not matched -- see +// vitest.live.config.mts for why it runs on a different trigger. +export default defineConfig({ + test: { + include: ['test/**/*.test.mts'], + }, +}) diff --git a/vitest.live.config.mts b/vitest.live.config.mts new file mode 100644 index 0000000..38bec4b --- /dev/null +++ b/vitest.live.config.mts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config' + +// The live contract check against the real docs.pmnd.rs. Split into its own config +// so it can never be picked up by `npm test`: a third party being down must not +// redden a pull request. CI runs it weekly instead, where the same failure is signal +// rather than noise. +export default defineConfig({ + test: { + include: ['test/**/*.live.mts'], + // One page fetch can be slow, and a retry storm against someone else's docs site + // is rude + testTimeout: 30_000, + retry: 0, + }, +}) From c2bd94c40dd15d1ecd9590b4f169f99f061183b0 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 11 Aug 2026 10:31:55 +0200 Subject: [PATCH 07/10] Keep only the tests that pin two files together Most of the offline suite was restating a file to itself: that SKILL.md has a name and a description, that .mcp.json holds the url .mcp.json holds. That is not a test, it is a copy, and it fails only when someone edits both halves in the same breath. There is no code in this plugin to unit-test. So the rule is now explicit: a check earns its place only by pinning a relationship between two files that nothing else enforces. Four survive -- the tool name in the skill against the server key in .mcp.json, the live check's endpoint against the declared one, the skill's served list against SERVED, and the marketplace against the plugin manifest. Rename the server key and the first one catches what would otherwise be Claude silently failing to find the tool. The real check remains the live one, which compares our claim to something we do not control. Also drops the test/ directory, which is not a plugin convention -- Claude Code discovers commands/, agents/, skills/, hooks/ and .mcp.json, and ignores the rest. Four files at the root read plainly for a repo this size. Co-Authored-By: Claude Opus 5 --- .github/workflows/docs-mcp.yml | 2 +- README.md | 14 +++- test/coverage.mts => coverage.mts | 0 test/docs-mcp.live.mts => docs-mcp.live.mts | 2 +- plugin.test.mts | 60 ++++++++++++++ test/plugin.test.mts | 92 --------------------- tsconfig.json | 2 +- vitest.config.mts | 2 +- vitest.live.config.mts | 2 +- 9 files changed, 75 insertions(+), 101 deletions(-) rename test/coverage.mts => coverage.mts (100%) rename test/docs-mcp.live.mts => docs-mcp.live.mts (97%) create mode 100644 plugin.test.mts delete mode 100644 test/plugin.test.mts diff --git a/.github/workflows/docs-mcp.yml b/.github/workflows/docs-mcp.yml index 0a10f9a..9304258 100644 --- a/.github/workflows/docs-mcp.yml +++ b/.github/workflows/docs-mcp.yml @@ -37,7 +37,7 @@ jobs: RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail - body=$(printf 'The weekly check against `https://docs.pmnd.rs/api/mcp` failed.\n\nEither the server is down, or its coverage moved and `skills/docs/SKILL.md` now tells Claude something untrue — in which case update `SERVED` in `test/coverage.mts` and the Coverage section of the skill. The assertion says which.\n\n```\n%s\n```\n\n[Full run](%s)\n' "$(tail -40 check.log)" "$RUN") + body=$(printf 'The weekly check against `https://docs.pmnd.rs/api/mcp` failed.\n\nEither the server is down, or its coverage moved and `skills/docs/SKILL.md` now tells Claude something untrue — in which case update `SERVED` in `coverage.mts` and the Coverage section of the skill. The assertion says which.\n\n```\n%s\n```\n\n[Full run](%s)\n' "$(tail -40 check.log)" "$RUN") # // empty, else jq prints the string "null" for an empty list and we would # try to comment on issue null diff --git a/README.md b/README.md index 4843740..fc962dc 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,15 @@ claude plugin validate .claude-plugin/plugin.json npm test ``` -Offline checks over this repo's own files. `vitest.config.mts` matches -`test/**/*.test.mts` only, so this can never reach the network. It is what CI gates -pull requests on, alongside `npm run typecheck`. +Four offline checks, and deliberately no more. This plugin is configuration and +prose — there is no code to unit-test, and asserting that a file contains what it +contains proves nothing. A test earns its place here only by pinning a relationship +*between* two files that nothing else enforces: the tool name in the skill against +the server key in `.mcp.json`, the skill's served list against `coverage.mts`, the +marketplace against the plugin manifest. + +`vitest.config.mts` matches `*.test.mts` only, so this can never reach the network. +It is what CI gates pull requests on, alongside `npm run typecheck`. ``` npm run test:live @@ -62,7 +68,7 @@ reasons outside your patch, and it files an issue when it does. What it catches server's coverage drifting away from what `skills/docs/SKILL.md` claims — so it talks to the real server, since a mock would keep passing through exactly that drift. -Only the positive claim is maintained, in [`test/coverage.mts`](test/coverage.mts). +Only the positive claim is maintained, in [`coverage.mts`](coverage.mts). The complement is derived from the `lib` enum the server publishes, so a library that starts working — or a new one appearing in the enum — fails the check without anyone keeping a second list in sync. diff --git a/test/coverage.mts b/coverage.mts similarity index 100% rename from test/coverage.mts rename to coverage.mts diff --git a/test/docs-mcp.live.mts b/docs-mcp.live.mts similarity index 97% rename from test/docs-mcp.live.mts rename to docs-mcp.live.mts index 9b916ec..ce99ebb 100644 --- a/test/docs-mcp.live.mts +++ b/docs-mcp.live.mts @@ -99,7 +99,7 @@ describe('coverage claim', () => { expect( working.toSorted(), - `update SERVED in test/coverage.mts and the Coverage section of skills/docs/SKILL.md`, + `update SERVED in coverage.mts and the Coverage section of skills/docs/SKILL.md`, ).toEqual(SERVED.toSorted()) }) }) diff --git a/plugin.test.mts b/plugin.test.mts new file mode 100644 index 0000000..7413979 --- /dev/null +++ b/plugin.test.mts @@ -0,0 +1,60 @@ +// Offline checks, deliberately few. +// +// This plugin is configuration and prose -- there is no code here to unit-test, and +// asserting that a file contains what it contains proves nothing. So the rule is: a +// test earns its place only by pinning a relationship *between* two files that +// nothing else enforces. Everything else was dropped. +// +// npm test + +import { readFile } from 'node:fs/promises' +import { describe, expect, test } from 'vitest' +import { ENDPOINT, SERVED } from './coverage.mts' + +const read = (p: string) => readFile(new URL(p, import.meta.url), 'utf8') +const readJson = async (p: string) => JSON.parse(await read(p)) + +const skill = () => read('skills/docs/SKILL.md') + +describe('SKILL.md <-> .mcp.json', () => { + test('the tool the skill calls is the one the server key namespaces', async () => { + const { mcpServers } = await readJson('.mcp.json') + const [key] = Object.keys(mcpServers) + + // Rename the server key and every mcp__pmndrs__* in the skill goes stale, with + // no error anywhere -- Claude just silently stops finding the tool + expect(await skill()).toContain(`mcp__${key}__get_page_content`) + }) + + test('the live check probes the server the plugin actually declares', async () => { + const { mcpServers } = await readJson('.mcp.json') + + // Otherwise docs-mcp.live.mts happily verifies a server nobody connects to + expect(Object.values(mcpServers).map((s) => (s as { url: string }).url)).toContain(ENDPOINT) + }) +}) + +describe('SKILL.md <-> coverage.mts', () => { + test('the skill lists exactly the libraries we claim are served', async () => { + const section = (await skill()).split('## Coverage')[1]?.split('\n## ')[0] + expect(section, 'SKILL.md lost its "## Coverage" section').toBeDefined() + + const served = section!.split(/\n\s*\n/).find((p) => p.trim().startsWith('Served')) + expect(served, 'Coverage needs a paragraph starting with "Served"').toBeDefined() + + // Set equality: when the live check fails and someone updates SERVED, this is what + // makes them update the prose Claude actually reads. Adding a name to one side + // only is the bug, in either direction. + const named = [...served!.matchAll(/`([^`]+)`/g)].map((m) => m[1]!) + expect(named.toSorted()).toEqual(SERVED.toSorted()) + }) +}) + +describe('plugin.json <-> marketplace.json', () => { + test('the marketplace publishes the plugin this repo defines', async () => { + const plugin = await readJson('.claude-plugin/plugin.json') + const marketplace = await readJson('.claude-plugin/marketplace.json') + + expect(marketplace.plugins.map((p: { name: string }) => p.name)).toContain(plugin.name) + }) +}) diff --git a/test/plugin.test.mts b/test/plugin.test.mts deleted file mode 100644 index 7a6d71e..0000000 --- a/test/plugin.test.mts +++ /dev/null @@ -1,92 +0,0 @@ -// Offline checks over the plugin's own files. No network: a PR here must never go red -// because a third party is having a bad day. The live server is checked separately, -// on a schedule, by docs-mcp.live.mts. -// -// npm test - -import { readFile } from 'node:fs/promises' -import { describe, expect, test } from 'vitest' -import { ENDPOINT, SERVED } from './coverage.mts' - -const read = (p: string) => readFile(new URL(`../${p}`, import.meta.url), 'utf8') -const readJson = async (p: string) => JSON.parse(await read(p)) - -const skill = () => read('skills/docs/SKILL.md') - -describe('.mcp.json', () => { - test('declares the pmndrs docs server over HTTP', async () => { - const { mcpServers } = await readJson('.mcp.json') - - expect(Object.keys(mcpServers)).toEqual(['pmndrs']) - expect(mcpServers.pmndrs.type).toBe('http') - // The server key is what namespaces the tools, so SKILL.md can only call them - // mcp__pmndrs__* while this stays 'pmndrs' - expect(mcpServers.pmndrs.url).toBe(ENDPOINT) - }) -}) - -describe('skills/docs', () => { - test('has the frontmatter Claude needs to trigger it', async () => { - const [, frontmatter] = (await skill()).split('---\n', 2) - - expect(frontmatter).toMatch(/^name: docs$/m) - expect(frontmatter).toMatch(/^description: \S/m) - }) - - test('calls the MCP tool by its namespaced name', async () => { - expect( - await skill(), - 'the tool name must match the .mcp.json server key', - ).toContain('mcp__pmndrs__get_page_content') - }) - - // The Coverage section states which libraries are worth routing through the MCP. - // Prose drifts; this keeps it pinned to coverage.mts, which the live check pins to - // the real server. - describe('Coverage section', () => { - const coverage = async () => { - const section = (await skill()).split('## Coverage')[1]?.split('\n## ')[0] - expect(section, 'SKILL.md lost its "## Coverage" section').toBeDefined() - - const paragraphs = section!.split(/\n\s*\n/).filter((p) => p.trim()) - const served = paragraphs.find((p) => p.trim().startsWith('Served')) - expect(served, 'Coverage needs a paragraph starting with "Served"').toBeDefined() - - return { section: section!, served: served! } - } - - test('lists exactly the served libraries, no more and no fewer', async () => { - const { served } = await coverage() - const named = [...served.matchAll(/`([^`]+)`/g)].map((m) => m[1]!) - - // Set equality, not "includes": a library quietly added to the prose without - // being added to SERVED is the same bug as one silently dropped - expect(named.toSorted()).toEqual(SERVED.toSorted()) - }) - - // Deliberately not asserted: that the rest of the section names no library. Prose - // can enumerate them a dozen ways and a regex would only pretend to catch it. The - // load-bearing guarantee is the set equality above -- as long as the served list - // is exact, a stale name elsewhere misleads about nothing Claude acts on. - test('points everything else at WebFetch', async () => { - const { section, served } = await coverage() - const rest = section.replace(served, '') - - expect(rest).toMatch(/unserved/i) - expect(rest).toContain('WebFetch') - }) - }) -}) - -describe('manifests', () => { - test('plugin.json names the plugin the marketplace publishes', async () => { - const plugin = await readJson('.claude-plugin/plugin.json') - const marketplace = await readJson('.claude-plugin/marketplace.json') - - expect(plugin.name).toBe('pmndrs') - expect( - marketplace.plugins.map((p: { name: string }) => p.name), - `marketplace.json publishes no plugin named ${plugin.name}`, - ).toContain(plugin.name) - }) -}) diff --git a/tsconfig.json b/tsconfig.json index d939f52..bffbeda 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "allowImportingTsExtensions": true, "noEmit": true }, - "include": ["test", "*.mts"] + "include": ["*.mts"] } diff --git a/vitest.config.mts b/vitest.config.mts index f67d96c..0319fec 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -4,6 +4,6 @@ import { defineConfig } from 'vitest/config' // vitest.live.config.mts for why it runs on a different trigger. export default defineConfig({ test: { - include: ['test/**/*.test.mts'], + include: ['*.test.mts'], }, }) diff --git a/vitest.live.config.mts b/vitest.live.config.mts index 38bec4b..df45d0b 100644 --- a/vitest.live.config.mts +++ b/vitest.live.config.mts @@ -6,7 +6,7 @@ import { defineConfig } from 'vitest/config' // rather than noise. export default defineConfig({ test: { - include: ['test/**/*.live.mts'], + include: ['*.live.mts'], // One page fetch can be slow, and a retry storm against someone else's docs site // is rude testTimeout: 30_000, From 7e32a8b4163412e6df84d6afb3b3d4d2081c20d0 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 11 Aug 2026 10:37:28 +0200 Subject: [PATCH 08/10] Drop the live server check -- not this plugin's job Whether docs.pmnd.rs serves what it advertises is the docs site's responsibility, and it is tested there. A consumer asserting things about its provider only duplicates that, on a slower loop, from a repo with no power to fix what it finds. Takes the weekly workflow and its issue-filing with it, and coverage.mts too: SERVED only meant something while a live probe anchored it to reality. Without that, it was a second hand-written copy of what the skill already says -- the same tautology this suite was just pruned of. Two checks left, both pinning files that would otherwise drift apart in silence: the tool name in the skill against the server key in .mcp.json, and the marketplace against the plugin manifest. Co-Authored-By: Claude Opus 5 --- .github/workflows/docs-mcp.yml | 50 ---------------- README.md | 28 +++------ coverage.mts | 10 ---- docs-mcp.live.mts | 105 --------------------------------- package.json | 1 - plugin.test.mts | 60 +++++-------------- vitest.live.config.mts | 15 ----- 7 files changed, 22 insertions(+), 247 deletions(-) delete mode 100644 .github/workflows/docs-mcp.yml delete mode 100644 coverage.mts delete mode 100644 docs-mcp.live.mts delete mode 100644 vitest.live.config.mts diff --git a/.github/workflows/docs-mcp.yml b/.github/workflows/docs-mcp.yml deleted file mode 100644 index 9304258..0000000 --- a/.github/workflows/docs-mcp.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: docs-mcp - -# Checks the live docs.pmnd.rs MCP server against what skills/docs/SKILL.md claims. -# Never on pull_request: this can only fail for reasons outside the contributor's -# patch, and a build that goes red for someone else's outage is a build people learn -# to ignore. On a schedule, the same failure is signal -- the server's coverage moved -# and the skill now says something untrue. -on: - schedule: - - cron: '0 6 * * 1' - workflow_dispatch: - -jobs: - check: - runs-on: ubuntu-latest - permissions: - issues: write - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: npm - - run: npm ci - - # shell: bash for pipefail -- the default shell is `bash -e` without it, so tee - # would swallow a failing exit status and the check would always look green - - shell: bash - run: npm run test:live 2>&1 | tee check.log - - # A scheduled run nobody watches is worth nothing, so surface it where the work - # actually gets picked up. One issue, reopened and updated rather than piled up. - - if: failure() - shell: bash - env: - GH_TOKEN: ${{ github.token }} - RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - set -euo pipefail - body=$(printf 'The weekly check against `https://docs.pmnd.rs/api/mcp` failed.\n\nEither the server is down, or its coverage moved and `skills/docs/SKILL.md` now tells Claude something untrue — in which case update `SERVED` in `coverage.mts` and the Coverage section of the skill. The assertion says which.\n\n```\n%s\n```\n\n[Full run](%s)\n' "$(tail -40 check.log)" "$RUN") - - # // empty, else jq prints the string "null" for an empty list and we would - # try to comment on issue null - existing=$(gh issue list --state open --label docs-mcp --limit 1 --json number -q '.[0].number // empty') - if [ -n "$existing" ]; then - gh issue comment "$existing" --body "$body" - else - gh label create docs-mcp --description "Live docs MCP server check" --color FBCA04 --force - gh issue create --title "docs MCP server check is failing" --label docs-mcp --body "$body" - fi diff --git a/README.md b/README.md index fc962dc..1fabfac 100644 --- a/README.md +++ b/README.md @@ -48,30 +48,16 @@ claude plugin validate .claude-plugin/plugin.json npm test ``` -Four offline checks, and deliberately no more. This plugin is configuration and +Two offline checks, and deliberately no more. This plugin is configuration and prose — there is no code to unit-test, and asserting that a file contains what it -contains proves nothing. A test earns its place here only by pinning a relationship +contains proves nothing. A check earns its place only by pinning a relationship *between* two files that nothing else enforces: the tool name in the skill against -the server key in `.mcp.json`, the skill's served list against `coverage.mts`, the -marketplace against the plugin manifest. +the server key in `.mcp.json`, and the marketplace against the plugin manifest. -`vitest.config.mts` matches `*.test.mts` only, so this can never reach the network. -It is what CI gates pull requests on, alongside `npm run typecheck`. - -``` -npm run test:live -``` - -The live check against `https://docs.pmnd.rs/api/mcp`, on its own config so `npm test` -never picks it up. It runs weekly rather than on pull requests: it can only fail for -reasons outside your patch, and it files an issue when it does. What it catches is the -server's coverage drifting away from what `skills/docs/SKILL.md` claims — so it talks -to the real server, since a mock would keep passing through exactly that drift. - -Only the positive claim is maintained, in [`coverage.mts`](coverage.mts). -The complement is derived from the `lib` enum the server publishes, so a library that -starts working — or a new one appearing in the enum — fails the check without anyone -keeping a second list in sync. +Whether `https://docs.pmnd.rs/api/mcp` serves what it claims is not this repo's +business — that belongs to [pmndrs/docs](https://github.com/pmndrs/docs), which +tests it there. The Coverage section of the skill records what was true when it was +written; when the server widens, widen the prose. ## License diff --git a/coverage.mts b/coverage.mts deleted file mode 100644 index 6418bf8..0000000 --- a/coverage.mts +++ /dev/null @@ -1,10 +0,0 @@ -// What skills/docs/SKILL.md claims about the docs MCP server, as data. -// -// Only the positive claim is written down. The complement is derived from the `lib` -// enum the server publishes, so a library that starts working -- or a new one -// appearing in the enum -- is caught without anyone maintaining a second list. - -export const ENDPOINT = 'https://docs.pmnd.rs/api/mcp' - -/** Libraries whose docs site publishes the /llms-full.txt dump the server parses. */ -export const SERVED = ['react-three-fiber', 'drei', 'zustand', 'docs'] diff --git a/docs-mcp.live.mts b/docs-mcp.live.mts deleted file mode 100644 index ce99ebb..0000000 --- a/docs-mcp.live.mts +++ /dev/null @@ -1,105 +0,0 @@ -// Live contract check against the real docs.pmnd.rs MCP server. -// -// npm run test:live -// -// Not matched by vitest.config.mts, so `npm test` never reaches the network. CI runs -// this weekly, where a failure means the world moved rather than that someone's patch -// is wrong. - -import { describe, expect, test } from 'vitest' -import { ENDPOINT, SERVED } from './coverage.mts' - -async function rpc(method: string, params: Record = {}) { - const res = await fetch(ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - signal: AbortSignal.timeout(30_000), - }) - expect(res.ok, `${method} -> HTTP ${res.status}`).toBe(true) - - // The server answers over SSE even for a single response - const body = await res.text() - const line = body.split('\n').find((l) => l.startsWith('data: ')) - expect(line, `${method} -> no data frame in: ${body.slice(0, 200)}`).toBeDefined() - return JSON.parse(line!.slice(6)) -} - -/** Every library the server advertises -- its own enum is the authority, not us. */ -async function advertised(): Promise { - const { result } = await rpc('tools/list') - const tool = result.tools.find((t: { name: string }) => t.name === 'get_page_content') - expect(tool, 'get_page_content is gone -- SKILL.md calls it by name').toBeDefined() - return tool.inputSchema.properties.lib.enum -} - -/** Index text for a library, or '' when the server has no dump to parse. */ -async function indexOf(lib: string): Promise { - const { result, error } = await rpc('resources/read', { uri: `docs://${lib}/index` }) - return error ? '' : result.contents[0].text -} - -describe('server contract', () => { - test('speaks MCP and identifies as pmndrs-docs', async () => { - const { result } = await rpc('initialize', { - protocolVersion: '2025-06-18', - capabilities: {}, - clientInfo: { name: 'pmndrs-plugin-test', version: '0' }, - }) - - expect(result.serverInfo.name).toBe('pmndrs-docs') - }) - - test('exposes get_page_content taking a lib and a path', async () => { - const { result } = await rpc('tools/list') - const tool = result.tools.find((t: { name: string }) => t.name === 'get_page_content') - - expect(tool.inputSchema.required.toSorted()).toEqual(['lib', 'path']) - }) -}) - -// The workflow SKILL.md prescribes: read the index, take a path from it verbatim, -// fetch that page. If this passes, documentation lookup genuinely works end to end. -describe('index-then-fetch', () => { - test.for(SERVED)('%s serves an index and its pages', async (lib) => { - const index = await indexOf(lib) - expect(index, `${lib} index is empty -- its llms-full.txt is missing`).not.toBe('') - - const entries = index.split('\n').filter(Boolean) - for (const entry of entries) { - expect(entry, `${lib} index line is not "{path} - {title}"`).toMatch(/^\/\S* - .+$/) - } - - const path = entries[0]!.split(' - ')[0]! - const { result, error } = await rpc('tools/call', { - name: 'get_page_content', - arguments: { lib, path }, - }) - expect(error, `${lib} ${path} -> ${error?.message}`).toBeUndefined() - - const content: string = result.content[0].text - expect(content, `${lib} ${path} -> ${content}`).not.toMatch(/^MCP server error/) - expect(content.length, `${lib} ${path} -> looks empty`).toBeGreaterThan(100) - }) -}) - -describe('coverage claim', () => { - // The one assertion that keeps SERVED honest in both directions, over whatever set - // of libraries the server happens to advertise today. A library that starts working - // fails this just as loudly as one that stops. - test('the libraries that actually work are exactly the ones we claim', async () => { - const libs = await advertised() - const working: string[] = [] - for (const lib of libs) { - if ((await indexOf(lib)) !== '') working.push(lib) - } - - expect( - working.toSorted(), - `update SERVED in coverage.mts and the Coverage section of skills/docs/SKILL.md`, - ).toEqual(SERVED.toSorted()) - }) -}) diff --git a/package.json b/package.json index c0cc427..9121a1f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "type": "module", "scripts": { "test": "vitest run", - "test:live": "vitest run --config vitest.live.config.mts", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/plugin.test.mts b/plugin.test.mts index 7413979..8219a7d 100644 --- a/plugin.test.mts +++ b/plugin.test.mts @@ -1,60 +1,30 @@ -// Offline checks, deliberately few. +// Offline checks, deliberately two. // // This plugin is configuration and prose -- there is no code here to unit-test, and -// asserting that a file contains what it contains proves nothing. So the rule is: a -// test earns its place only by pinning a relationship *between* two files that -// nothing else enforces. Everything else was dropped. +// asserting that a file contains what it contains proves nothing. A check earns its +// place only by pinning a relationship *between* two files that nothing else +// enforces, and only two such relationships exist. // // npm test import { readFile } from 'node:fs/promises' -import { describe, expect, test } from 'vitest' -import { ENDPOINT, SERVED } from './coverage.mts' +import { expect, test } from 'vitest' const read = (p: string) => readFile(new URL(p, import.meta.url), 'utf8') const readJson = async (p: string) => JSON.parse(await read(p)) -const skill = () => read('skills/docs/SKILL.md') +test('the tool the skill calls is the one the server key namespaces', async () => { + const { mcpServers } = await readJson('.mcp.json') + const [key] = Object.keys(mcpServers) -describe('SKILL.md <-> .mcp.json', () => { - test('the tool the skill calls is the one the server key namespaces', async () => { - const { mcpServers } = await readJson('.mcp.json') - const [key] = Object.keys(mcpServers) - - // Rename the server key and every mcp__pmndrs__* in the skill goes stale, with - // no error anywhere -- Claude just silently stops finding the tool - expect(await skill()).toContain(`mcp__${key}__get_page_content`) - }) - - test('the live check probes the server the plugin actually declares', async () => { - const { mcpServers } = await readJson('.mcp.json') - - // Otherwise docs-mcp.live.mts happily verifies a server nobody connects to - expect(Object.values(mcpServers).map((s) => (s as { url: string }).url)).toContain(ENDPOINT) - }) -}) - -describe('SKILL.md <-> coverage.mts', () => { - test('the skill lists exactly the libraries we claim are served', async () => { - const section = (await skill()).split('## Coverage')[1]?.split('\n## ')[0] - expect(section, 'SKILL.md lost its "## Coverage" section').toBeDefined() - - const served = section!.split(/\n\s*\n/).find((p) => p.trim().startsWith('Served')) - expect(served, 'Coverage needs a paragraph starting with "Served"').toBeDefined() - - // Set equality: when the live check fails and someone updates SERVED, this is what - // makes them update the prose Claude actually reads. Adding a name to one side - // only is the bug, in either direction. - const named = [...served!.matchAll(/`([^`]+)`/g)].map((m) => m[1]!) - expect(named.toSorted()).toEqual(SERVED.toSorted()) - }) + // Rename the server key and every mcp__pmndrs__* in the skill goes stale with no + // error anywhere -- Claude just quietly stops finding the tool + expect(await read('skills/docs/SKILL.md')).toContain(`mcp__${key}__get_page_content`) }) -describe('plugin.json <-> marketplace.json', () => { - test('the marketplace publishes the plugin this repo defines', async () => { - const plugin = await readJson('.claude-plugin/plugin.json') - const marketplace = await readJson('.claude-plugin/marketplace.json') +test('the marketplace publishes the plugin this repo defines', async () => { + const plugin = await readJson('.claude-plugin/plugin.json') + const marketplace = await readJson('.claude-plugin/marketplace.json') - expect(marketplace.plugins.map((p: { name: string }) => p.name)).toContain(plugin.name) - }) + expect(marketplace.plugins.map((p: { name: string }) => p.name)).toContain(plugin.name) }) diff --git a/vitest.live.config.mts b/vitest.live.config.mts deleted file mode 100644 index df45d0b..0000000 --- a/vitest.live.config.mts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vitest/config' - -// The live contract check against the real docs.pmnd.rs. Split into its own config -// so it can never be picked up by `npm test`: a third party being down must not -// redden a pull request. CI runs it weekly instead, where the same failure is signal -// rather than noise. -export default defineConfig({ - test: { - include: ['*.live.mts'], - // One page fetch can be slow, and a retry storm against someone else's docs site - // is rude - testTimeout: 30_000, - retry: 0, - }, -}) From 382e9945ed9ef7349badf1060b49253da9252157 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 11 Aug 2026 10:42:00 +0200 Subject: [PATCH 09/10] Drop the test suite and the toolchain under it Two assertions were left, and they were carrying a package.json, a lockfile, vitest, a tsconfig and a CI job. For a repo that ships configuration and prose, that is more machinery than the thing it guards. Neither was quite tautological, so they are written down instead of deleted. Both are edits that fail silently: renaming an .mcp.json server key invalidates every mcp____* in the skills with no error anywhere, and a plugins[].name in marketplace.json that disagrees with plugin.json breaks /plugin install while `claude plugin validate` still passes -- checked, it does pass. AGENTS.md now says so, which is where someone about to make either edit will actually be reading. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 26 - AGENTS.md | 15 + README.md | 14 +- package-lock.json | 1203 -------------------------------------- package.json | 14 - plugin.test.mts | 30 - tsconfig.json | 14 - vitest.config.mts | 9 - 8 files changed, 18 insertions(+), 1307 deletions(-) delete mode 100644 .github/workflows/ci.yml delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 plugin.test.mts delete mode 100644 tsconfig.json delete mode 100644 vitest.config.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index ee04fe1..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: ci - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: npm - - run: npm ci - - run: npm run typecheck - # Offline by construction: vitest.config.mts matches test/**/*.test.mts only, - # which leaves the live check out - - run: npm test diff --git a/AGENTS.md b/AGENTS.md index 68c2c84..566ead6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,21 @@ published as the `pmndrs` plugin from the `pmndrs` marketplace. Generalist: nothing here is scoped to a single pmndrs library. A component that only helps one repo belongs in that repo, not here. +## Couplings nothing enforces + +There is no build and no test suite here — this is configuration and prose. Two +edits break things silently, so check them by hand: + +- **Renaming an `.mcp.json` server key** invalidates every `mcp____*` in the + skills. Nothing errors; Claude just stops finding the tool. Grep for the old key. +- **`plugins[].name` in `marketplace.json`** is what `/plugin install @pmndrs` + resolves. `claude plugin validate` passes when it disagrees with + `plugin.json` — verified — so a mismatch only shows up as a failing install. + +Coverage claims in a skill (which libraries a server actually serves) are a +statement about someone else's deployment. Keep them in prose, and fix the server +rather than working around it here. + ## Agent skills ### Issue tracker diff --git a/README.md b/README.md index 1fabfac..715a789 100644 --- a/README.md +++ b/README.md @@ -42,17 +42,9 @@ claude plugin validate . claude plugin validate .claude-plugin/plugin.json ``` -## Test - -``` -npm test -``` - -Two offline checks, and deliberately no more. This plugin is configuration and -prose — there is no code to unit-test, and asserting that a file contains what it -contains proves nothing. A check earns its place only by pinning a relationship -*between* two files that nothing else enforces: the tool name in the skill against -the server key in `.mcp.json`, and the marketplace against the plugin manifest. +There is no test suite: this repo is configuration and prose, and a check that +reads a file back to itself proves nothing. The two edits that break things +silently are written down in [AGENTS.md](AGENTS.md). Whether `https://docs.pmnd.rs/api/mcp` serves what it claims is not this repo's business — that belongs to [pmndrs/docs](https://github.com/pmndrs/docs), which diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 73100df..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1203 +0,0 @@ -{ - "name": "@pmndrs/claude-code-plugin", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@pmndrs/claude-code-plugin", - "devDependencies": { - "@types/node": "^24.10.1", - "typescript": "^5.9.3", - "vitest": "^4.0.18" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@oxc-project/types": { - "version": "0.143.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", - "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", - "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", - "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", - "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", - "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", - "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", - "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", - "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", - "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", - "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", - "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", - "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", - "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", - "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", - "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.17", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rolldown": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", - "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.143.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.3", - "@rolldown/binding-darwin-arm64": "1.2.3", - "@rolldown/binding-darwin-x64": "1.2.3", - "@rolldown/binding-freebsd-x64": "1.2.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", - "@rolldown/binding-linux-arm64-gnu": "1.2.3", - "@rolldown/binding-linux-arm64-musl": "1.2.3", - "@rolldown/binding-linux-ppc64-gnu": "1.2.3", - "@rolldown/binding-linux-s390x-gnu": "1.2.3", - "@rolldown/binding-linux-x64-gnu": "1.2.3", - "@rolldown/binding-linux-x64-musl": "1.2.3", - "@rolldown/binding-openharmony-arm64": "1.2.3", - "@rolldown/binding-win32-arm64-msvc": "1.2.3", - "@rolldown/binding-win32-x64-msvc": "1.2.3" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", - "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.25", - "rolldown": "~1.2.1", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 9121a1f..0000000 --- a/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@pmndrs/claude-code-plugin", - "private": true, - "type": "module", - "scripts": { - "test": "vitest run", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "@types/node": "^24.10.1", - "typescript": "^5.9.3", - "vitest": "^4.0.18" - } -} diff --git a/plugin.test.mts b/plugin.test.mts deleted file mode 100644 index 8219a7d..0000000 --- a/plugin.test.mts +++ /dev/null @@ -1,30 +0,0 @@ -// Offline checks, deliberately two. -// -// This plugin is configuration and prose -- there is no code here to unit-test, and -// asserting that a file contains what it contains proves nothing. A check earns its -// place only by pinning a relationship *between* two files that nothing else -// enforces, and only two such relationships exist. -// -// npm test - -import { readFile } from 'node:fs/promises' -import { expect, test } from 'vitest' - -const read = (p: string) => readFile(new URL(p, import.meta.url), 'utf8') -const readJson = async (p: string) => JSON.parse(await read(p)) - -test('the tool the skill calls is the one the server key namespaces', async () => { - const { mcpServers } = await readJson('.mcp.json') - const [key] = Object.keys(mcpServers) - - // Rename the server key and every mcp__pmndrs__* in the skill goes stale with no - // error anywhere -- Claude just quietly stops finding the tool - expect(await read('skills/docs/SKILL.md')).toContain(`mcp__${key}__get_page_content`) -}) - -test('the marketplace publishes the plugin this repo defines', async () => { - const plugin = await readJson('.claude-plugin/plugin.json') - const marketplace = await readJson('.claude-plugin/marketplace.json') - - expect(marketplace.plugins.map((p: { name: string }) => p.name)).toContain(plugin.name) -}) diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index bffbeda..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "es2023", - "lib": ["es2023"], - "module": "nodenext", - "moduleResolution": "nodenext", - "types": ["node"], - "strict": true, - "noUncheckedIndexedAccess": true, - "allowImportingTsExtensions": true, - "noEmit": true - }, - "include": ["*.mts"] -} diff --git a/vitest.config.mts b/vitest.config.mts deleted file mode 100644 index 0319fec..0000000 --- a/vitest.config.mts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config' - -// Offline checks only. *.live.mts is deliberately not matched -- see -// vitest.live.config.mts for why it runs on a different trigger. -export default defineConfig({ - test: { - include: ['*.test.mts'], - }, -}) From d9f4f05f20234c8897ab91695fad76568d67dad9 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Tue, 11 Aug 2026 10:44:16 +0200 Subject: [PATCH 10/10] Drop the notes about what is not tested Both sections only existed to explain an absence. AGENTS.md and the README are for what the repo is, not for a record of what was considered and dropped. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 15 --------------- README.md | 9 --------- 2 files changed, 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 566ead6..68c2c84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,21 +6,6 @@ published as the `pmndrs` plugin from the `pmndrs` marketplace. Generalist: nothing here is scoped to a single pmndrs library. A component that only helps one repo belongs in that repo, not here. -## Couplings nothing enforces - -There is no build and no test suite here — this is configuration and prose. Two -edits break things silently, so check them by hand: - -- **Renaming an `.mcp.json` server key** invalidates every `mcp____*` in the - skills. Nothing errors; Claude just stops finding the tool. Grep for the old key. -- **`plugins[].name` in `marketplace.json`** is what `/plugin install @pmndrs` - resolves. `claude plugin validate` passes when it disagrees with - `plugin.json` — verified — so a mismatch only shows up as a failing install. - -Coverage claims in a skill (which libraries a server actually serves) are a -statement about someone else's deployment. Keep them in prose, and fix the server -rather than working around it here. - ## Agent skills ### Issue tracker diff --git a/README.md b/README.md index 715a789..fb893ac 100644 --- a/README.md +++ b/README.md @@ -42,15 +42,6 @@ claude plugin validate . claude plugin validate .claude-plugin/plugin.json ``` -There is no test suite: this repo is configuration and prose, and a check that -reads a file back to itself proves nothing. The two edits that break things -silently are written down in [AGENTS.md](AGENTS.md). - -Whether `https://docs.pmnd.rs/api/mcp` serves what it claims is not this repo's -business — that belongs to [pmndrs/docs](https://github.com/pmndrs/docs), which -tests it there. The Coverage section of the skill records what was true when it was -written; when the server widens, widen the prose. - ## License MIT