From 9331c974b6ef6dcce119d32917cf29fbf2809aaf Mon Sep 17 00:00:00 2001 From: dhuyet Date: Mon, 31 Aug 2026 15:27:23 +0200 Subject: [PATCH 01/14] feat: add MCP server for AI-generated Project and Changes Tours Adds a standalone Node.js/TypeScript package at packages/mcp-server/ exposing a workspace-confined stdio MCP server with two tools: create_project_tour (.tours/project.tour) and create_changes_tour (.tours/changes.tour). The server validates proposals deterministically (aggregated errors, Tour Anchor rules, V1 security boundary: no commands/when/uri, no active Markdown schemes, symlink confinement), applies the Git rules (merge-base, full-head-SHA check, STALE_HEAD, NO_CHANGES, uncommitted change handling) and atomically replaces the reserved tour file, then validates its output against the general CodeTour schema. Closes #1 --- packages/mcp-server/LICENSE.txt | 21 + packages/mcp-server/README.md | 164 +++ packages/mcp-server/package-lock.json | 1310 +++++++++++++++++ packages/mcp-server/package.json | 42 + packages/mcp-server/schema.json | 149 ++ packages/mcp-server/src/cli.ts | 78 + packages/mcp-server/src/codetour-schema.ts | 8 + packages/mcp-server/src/context.ts | 9 + packages/mcp-server/src/git.ts | 164 +++ packages/mcp-server/src/persistence.ts | 48 + packages/mcp-server/src/server.ts | 451 ++++++ packages/mcp-server/src/types.ts | 70 + packages/mcp-server/src/validation.ts | 470 ++++++ .../mcp-server/test/helpers/test-utils.ts | 147 ++ .../test/integration/changes-tour.test.ts | 503 +++++++ .../test/integration/project-tour.test.ts | 409 +++++ .../test/integration/security.test.ts | 113 ++ .../mcp-server/test/unit/validation.test.ts | 177 +++ packages/mcp-server/tsconfig.json | 19 + tsconfig.json | 1 + 20 files changed, 4353 insertions(+) create mode 100644 packages/mcp-server/LICENSE.txt create mode 100644 packages/mcp-server/README.md create mode 100644 packages/mcp-server/package-lock.json create mode 100644 packages/mcp-server/package.json create mode 100644 packages/mcp-server/schema.json create mode 100644 packages/mcp-server/src/cli.ts create mode 100644 packages/mcp-server/src/codetour-schema.ts create mode 100644 packages/mcp-server/src/context.ts create mode 100644 packages/mcp-server/src/git.ts create mode 100644 packages/mcp-server/src/persistence.ts create mode 100644 packages/mcp-server/src/server.ts create mode 100644 packages/mcp-server/src/types.ts create mode 100644 packages/mcp-server/src/validation.ts create mode 100644 packages/mcp-server/test/helpers/test-utils.ts create mode 100644 packages/mcp-server/test/integration/changes-tour.test.ts create mode 100644 packages/mcp-server/test/integration/project-tour.test.ts create mode 100644 packages/mcp-server/test/integration/security.test.ts create mode 100644 packages/mcp-server/test/unit/validation.test.ts create mode 100644 packages/mcp-server/tsconfig.json diff --git a/packages/mcp-server/LICENSE.txt b/packages/mcp-server/LICENSE.txt new file mode 100644 index 00000000..b2f52a2b --- /dev/null +++ b/packages/mcp-server/LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 00000000..7b52095a --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,164 @@ +# codetour-mcp + +A local MCP (Model Context Protocol) server that lets an AI agent create +[CodeTour](https://github.com/microsoft/codetour) tours deterministically. +The agent analyzes the code and writes the explanations; the server validates +the proposal, applies the Git and security rules, and atomically replaces the +reserved tour file. + +Two specialized tools are exposed: + +- `create_project_tour` — a **Project Tour** that explains a codebase as a + whole, written to `.tours/project.tour`. +- `create_changes_tour` — a **Changes Tour** that explains the committed + changes on a branch since it diverged from a base ref, written to + `.tours/changes.tour`. + +Both outputs are compatible with the general CodeTour schema, within a +deliberately stricter V1 subset: explanatory Markdown and workspace-internal +locations only. CodeTour `commands`, root-level `when` expressions, external +`uri` steps, and active Markdown schemes (`command:`, `file:`, `vscode:`, +`vscode-insiders:`, `javascript:`) are rejected. + +## Requirements + +- Node.js >= 18 +- Git (only for `create_changes_tour`) + +## Installation and local validation + +```bash +npm install +npm run build +npm test +``` + +Run the server locally: + +```bash +node dist/src/cli.js --workspace-root /path/to/workspace +``` + +The `--workspace-root` argument is required. One server instance handles +exactly one workspace root, and every operation is confined to it: real paths +are resolved before any read or write, symlinks that escape the root are +rejected, and the server performs no network access. + +The package exposes the `codetour-mcp` binary (available after `npm install` +or `npm link`). + +## MCP client configuration + +```json +{ + "mcpServers": { + "codetour": { + "command": "node", + "args": ["/path/to/codetour/packages/mcp-server/dist/src/cli.js", "--workspace-root", "/path/to/workspace"] + } + } +} +``` + +The transport is `stdio` only. + +## Tools + +### `create_project_tour` + +| Argument | Type | Required | Description | +| ------------- | -------- | -------- | ------------------------------------------------------- | +| `title` | string | no | Defaults to `Project Overview`. | +| `description` | string | no | Optional tour description. | +| `steps` | object[] | yes | Non-empty list of steps (see below). | + +A good Project Tour covers the project's purpose, its main entry points, its +important components, and its main execution flows. + +### `create_changes_tour` + +| Argument | Type | Required | Description | +| -------------------- | -------- | -------- | ------------------------------------------------------------- | +| `base` | string | yes | Git ref the branch diverged from. | +| `head` | string | yes | Full 40-character SHA of the analyzed commit; must equal the current `HEAD`. | +| `includeUncommitted` | boolean | no | Include uncommitted changes explicitly (default `false`). | +| `title` | string | no | Defaults to `Changes on `. | +| `description` | string | no | Optional description; provenance is always appended. | +| `steps` | object[] | yes | Non-empty list of steps (see below). | + +A good Changes Tour covers the intent of the changes, the major +modifications, their impact, and the relevant tests. + +### Steps + +| Field | Type | Description | +| ------------- | ------ | ---------------------------------------------------------------------------- | +| `title` | string | Optional step title. | +| `description` | string | Required Markdown explanation. | +| `file` | string | Workspace-relative path; at most one of `file`/`directory` per step. | +| `directory` | string | Workspace-relative path; at most one of `file`/`directory` per step. | +| `line` | number | 1-based line; only valid with `file`, mutually exclusive with `pattern`. | +| `pattern` | string | Regular expression matching exactly one occurrence; only valid with `file`. | +| `selection` | object | `{ start: {line, character}, end: {line, character} }`, 1-based; only valid with `file`. | + +Steps without any locator are allowed (general context, deleted files). +Every anchor is validated against the real workspace state. All validation +errors are aggregated and reported in a single response; the previous tour +file is preserved on failure. + +### Result + +Each successful tool call returns a human-readable message and a structure: + +```json +{ "status": "created", "path": ".tours/project.tour", "stepCount": 3, "warnings": [] } +``` + +Failures return `{ "status": "error", "code", "message", "issues" }` with one +of these codes: + +| Code | Meaning | +| -------------------------- | -------------------------------------------------------------- | +| `TOUR_STEPS_REQUIRED` | The steps list is missing or empty. | +| `INVALID_PROPOSAL` | The proposal has validation issues (all listed in `issues`). | +| `GIT_REPOSITORY_REQUIRED` | `create_changes_tour` was called outside a Git repository. | +| `STALE_HEAD` | `head` does not match the current `HEAD`. | +| `INVALID_BASE_REF` | The merge-base between `base` and `head` cannot be computed. | +| `NO_CHANGES` | No committed changes between the merge-base and the head; the previous tour file is preserved. | +| `SCHEMA_VALIDATION_FAILED` | Internal: the generated tour did not validate against the CodeTour schema. | +| `OUTPUT_PATH_ESCAPES_WORKSPACE` | The output directory resolves outside the workspace root. | + +Non-blocking warnings: + +| Code | Meaning | +| ------------------------------- | ------------------------------------------------------------------------ | +| `STEP_LIMIT_EXCEEDED` | The tour has more than fifteen steps. | +| `NO_CHANGED_FILE_ANCHOR` | No step anchors a file modified by the changes. | +| `UNCOMMITTED_CHANGES_EXCLUDED` | Staged, unstaged or untracked changes were excluded (default). | +| `UNCOMMITTED_CHANGES_INCLUDED` | Uncommitted changes were included; the tour describes a non-reproducible local state. | + +## Git reference policies + +- A Project Tour has no CodeTour `ref`, so it stays usable as the project + evolves. +- A reproducible Changes Tour records the exact analyzed head SHA as its + `ref`, and generation fails with `STALE_HEAD` if `HEAD` changed since the + analysis. Uncommitted changes are excluded by default (with a warning). +- With `includeUncommitted: true`, the Changes Tour has no `ref` and warns + that it describes a non-reproducible local state. + +The reserved tour files (`.tours/project.tour` and `.tours/changes.tour`) are +always replaced after a complete, successful validation, via an atomic +rename. They are excluded from the dirty-workspace detection so a previous +generation does not warn about itself. + +## Development + +```bash +npm run typecheck # type-check only +npm run build # compile to dist/ +npm test # build + integration tests over the stdio MCP seam +``` + +Integration tests launch the server as an MCP client would, over `stdio`, +against temporary workspaces and temporary Git repositories. diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json new file mode 100644 index 00000000..706baa98 --- /dev/null +++ b/packages/mcp-server/package-lock.json @@ -0,0 +1,1310 @@ +{ + "name": "codetour-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codetour-mcp", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "ajv": "^6.12.6", + "zod": "^3.25.76" + }, + "bin": { + "codetour-mcp": "dist/src/cli.js" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "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/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 00000000..96ad7332 --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,42 @@ +{ + "name": "codetour-mcp", + "version": "0.1.0", + "description": "Local MCP server that deterministically validates and persists AI-generated CodeTour Project Tours and Changes Tours", + "license": "MIT", + "author": { + "name": "Microsoft Corporation" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/codetour", + "directory": "packages/mcp-server" + }, + "bin": { + "codetour-mcp": "dist/src/cli.js" + }, + "main": "dist/src/server.js", + "files": [ + "dist/src", + "schema.json", + "README.md", + "LICENSE.txt" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "npm run build && node --test \"dist/test/**/*.test.js\"", + "start": "node dist/src/cli.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "ajv": "^6.12.6", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.8.0" + } +} diff --git a/packages/mcp-server/schema.json b/packages/mcp-server/schema.json new file mode 100644 index 00000000..ea8e63ab --- /dev/null +++ b/packages/mcp-server/schema.json @@ -0,0 +1,149 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Schema for CodeTour tour files", + "type": "object", + "required": ["title", "steps"], + "properties": { + "title": { + "type": "string", + "description": "Specifies the title of the code tour." + }, + "description": { + "type": "string", + "description": "Specifies an optional description for the code tour." + }, + "ref": { + "type": "string", + "description": "Indicates the git ref (branch/commit/tag) that this tour associate with." + }, + "isPrimary": { + "type": "boolean", + "description": "Specifies whether the tour represents the primary tour for this codebase." + }, + "steps": { + "type": "array", + "description": "Specifies the list of steps that are included in the code tour.", + "default": [], + "items": { + "type": "object", + "required": ["description"], + "properties": { + "file": { + "type": "string", + "description": "File path (relative to the workspace root) that the step is associated with." + }, + "directory": { + "type": "string", + "description": "Directory path (relative to the workspace root) that the step is associated with." + }, + "view": { + "anyOf": [ + { + "type": "string", + "enum": [ + "debug", + "debug:breakpoints", + "debug:callstack", + "debug:variables", + "debug:watch", + "explorer", + "extensions", + "extensions:disabled", + "extensions:enabled", + "output", + "problems", + "scm", + "search", + "terminal" + ], + "description": "The view ID (e.g. gistpad.gists) that this step is associated with." + }, + { + "type": "string", + "minLength": 1, + "description": "The view ID (e.g. gistpad.gists) that this step is associated with." + } + ] + }, + "uri": { + "type": "string", + "description": "Absolute URI that is associated with the step." + }, + "line": { + "type": "number", + "description": "Line number that the step is associated with." + }, + "pattern": { + "type": "string", + "description": "A regular expression to associate the step with. This is only considered when the line property isn't set, and allows you to associate steps with line content as opposed to ordinal." + }, + "title": { + "type": "string", + "description": "An optional title for the step." + }, + "description": { + "type": "string", + "description": "Description of the step." + }, + "selection": { + "type": "object", + "required": ["start", "end"], + "description": "Text selection that's associated with the step.", + "properties": { + "start": { + "type": "object", + "required": ["line", "character"], + "description": "Starting position (line, column) of the text selection range.", + "properties": { + "line": { + "type": "number", + "description": "Line number (1-based) that the text selection begins on." + }, + "character": { + "type": "number", + "description": "Column number (1-based) that the text selection begins on." + } + } + }, + "end": { + "type": "object", + "required": ["line", "character"], + "description": "Ending position (line, column) of the text selection range.", + "properties": { + "line": { + "type": "number", + "description": "Line number (1-based) that the text selection ends on." + }, + "character": { + "type": "number", + "description": "Column number (1-based) that the text selection end on." + } + } + } + } + }, + "commands": { + "type": "array", + "description": "Specifies an array of command URIs that will be executed when this step is navigated to.", + "default": [], + "items": { + "type": "string" + } + } + } + } + }, + "stepMarker": { + "type": "string", + "description": "Specifies the 'marker' that indicates a line of code represents a step for this tour." + }, + "nextTour": { + "type": "string", + "description": "Specifies the title of the tour that is meant to follow this tour." + }, + "when": { + "type": "string", + "description": "Specifies the condition that must be met before this tour is shown. The value of this property is a string that is evaluated as JavaScript." + } + } +} diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts new file mode 100644 index 00000000..a6d60ab5 --- /dev/null +++ b/packages/mcp-server/src/cli.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env node +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createContext } from "./context"; +import { createServer } from "./server"; +import packageJson from "../package.json"; + +interface ParsedArgs { + workspaceRoot?: string; + help: boolean; + version: boolean; +} + +function usage(): string { + return [ + `codetour-mcp v${packageJson.version}`, + "Local MCP server for AI-generated CodeTour Project Tours and Changes Tours.", + "", + "Usage: codetour-mcp --workspace-root ", + "", + "Options:", + " --workspace-root Workspace root that all operations are confined to (required)", + " --help, -h Show this help", + " --version, -v Show the version", + ].join("\n"); +} + +function parseArgs(argv: string[]): ParsedArgs { + const result: ParsedArgs = { help: false, version: false }; + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--help" || argument === "-h") { + result.help = true; + } else if (argument === "--version" || argument === "-v") { + result.version = true; + } else if (argument === "--workspace-root") { + result.workspaceRoot = argv[++index]; + } else if (argument.startsWith("--workspace-root=")) { + result.workspaceRoot = argument.slice("--workspace-root=".length); + } else { + console.error(`Unknown argument: ${argument}\n\n${usage()}`); + process.exit(1); + } + } + return result; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log(usage()); + return; + } + if (args.version) { + console.log(packageJson.version); + return; + } + if (!args.workspaceRoot) { + console.error(`Error: --workspace-root is required.\n\n${usage()}`); + process.exit(1); + } + try { + createContext(args.workspaceRoot); + } catch { + console.error( + `Error: the workspace root is not an accessible directory: ${args.workspaceRoot}` + ); + process.exit(1); + } + + const server = createServer(args.workspaceRoot); + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/mcp-server/src/codetour-schema.ts b/packages/mcp-server/src/codetour-schema.ts new file mode 100644 index 00000000..d2fc2312 --- /dev/null +++ b/packages/mcp-server/src/codetour-schema.ts @@ -0,0 +1,8 @@ +import Ajv from "ajv"; +import draft04MetaSchema from "ajv/lib/refs/json-schema-draft-04.json"; +import codetourSchema from "../schema.json"; + +const ajv = new Ajv({ allErrors: true, meta: false, schemaId: "id" }); +ajv.addMetaSchema(draft04MetaSchema); + +export const validateCodetourTour = ajv.compile(codetourSchema); diff --git a/packages/mcp-server/src/context.ts b/packages/mcp-server/src/context.ts new file mode 100644 index 00000000..adaa3f1f --- /dev/null +++ b/packages/mcp-server/src/context.ts @@ -0,0 +1,9 @@ +import * as fs from "fs"; +import * as path from "path"; +import { WorkspaceContext } from "./types"; + +export function createContext(workspaceRoot: string): WorkspaceContext { + const root = path.resolve(workspaceRoot); + const realRoot = fs.realpathSync(root); + return { root, realRoot }; +} diff --git a/packages/mcp-server/src/git.ts b/packages/mcp-server/src/git.ts new file mode 100644 index 00000000..56a196f7 --- /dev/null +++ b/packages/mcp-server/src/git.ts @@ -0,0 +1,164 @@ +import { execFile } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import { WorkspaceContext } from "./types"; + +export interface GitResult { + stdout: string; + stderr: string; + code: number; +} + +export function git(args: string[], cwd: string): Promise { + return new Promise((resolve) => { + execFile( + "git", + args, + { cwd, encoding: "utf8", maxBuffer: 128 * 1024 * 1024 }, + (error, stdout, stderr) => { + const code = + error == null + ? 0 + : typeof (error as { code?: unknown }).code === "number" + ? ((error as { code?: unknown }).code as number) + : 1; + resolve({ stdout: stdout ?? "", stderr: stderr ?? "", code }); + } + ); + }); +} + +export async function isGitRepository(ctx: WorkspaceContext): Promise { + const result = await git(["rev-parse", "--is-inside-work-tree"], ctx.root); + return result.code === 0 && result.stdout.trim() === "true"; +} + +export async function currentHeadSha( + ctx: WorkspaceContext +): Promise { + const result = await git(["rev-parse", "HEAD"], ctx.root); + return result.code === 0 ? result.stdout.trim() : null; +} + +export async function currentBranchName( + ctx: WorkspaceContext +): Promise { + const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], ctx.root); + return result.code === 0 ? result.stdout.trim() : null; +} + +export async function mergeBase( + ctx: WorkspaceContext, + a: string, + b: string +): Promise { + const result = await git(["merge-base", a, b], ctx.root); + if (result.code !== 0) { + throw new Error( + result.stderr.trim() || `git merge-base failed (exit code ${result.code})` + ); + } + return result.stdout.trim(); +} + +export async function committedDiffIsEmpty( + ctx: WorkspaceContext, + a: string, + b: string +): Promise { + const result = await git(["diff", "--quiet", a, b], ctx.root); + return result.code === 0; +} + +export async function changedFiles( + ctx: WorkspaceContext, + a: string, + b: string +): Promise { + const result = await git(["diff", "--name-only", "-z", a, b], ctx.root); + if (result.code !== 0) { + throw new Error( + result.stderr.trim() || `git diff failed (exit code ${result.code})` + ); + } + return result.stdout.split("\0").filter((entry) => entry.length > 0); +} + +export async function workspacePrefix(ctx: WorkspaceContext): Promise { + const result = await git(["rev-parse", "--show-prefix"], ctx.root); + return result.code === 0 ? result.stdout.trim() : ""; +} + +export const RESERVED_TOUR_FILES = [ + ".tours/project.tour", + ".tours/changes.tour", +]; + +export interface UncommittedEntry { + path: string; + status: string; +} + +export function normalizeSlashes(value: string): string { + return value.replace(/\\/g, "/"); +} + +export async function uncommittedChanges( + ctx: WorkspaceContext +): Promise { + const result = await git( + ["status", "--porcelain=v1", "--untracked-files=normal"], + ctx.root + ); + if (result.code !== 0) { + return []; + } + const prefix = normalizeSlashes(await workspacePrefix(ctx)); + const entries: UncommittedEntry[] = []; + for (const line of result.stdout.split("\n")) { + if (line.length < 4) { + continue; + } + const status = line.slice(0, 2); + let repoPath = line.slice(3); + if ((status.includes("R") || status.includes("C")) && repoPath.includes(" -> ")) { + repoPath = repoPath.slice(0, repoPath.indexOf(" -> ")); + } + if (!normalizeSlashes(repoPath).startsWith(prefix)) { + continue; + } + let workspacePath = normalizeSlashes(repoPath).slice(prefix.length); + if (status === "??" && workspacePath.endsWith("/")) { + const directory = workspacePath.slice(0, -1); + if (directory === ".tours" && toursDirectoryOnlyContainsReserved(ctx, directory)) { + continue; + } + } + if (RESERVED_TOUR_FILES.includes(workspacePath)) { + continue; + } + entries.push({ path: workspacePath, status }); + } + return entries; +} + +function toursDirectoryOnlyContainsReserved( + ctx: WorkspaceContext, + directory: string +): boolean { + const absolute = path.resolve(ctx.root, directory); + let files: string[]; + try { + files = fs.readdirSync(absolute, { recursive: true }) as string[]; + } catch { + return false; + } + return ( + files.length > 0 && + files.every((file) => + RESERVED_TOUR_FILES.includes( + normalizeSlashes(path.posix.join(directory, file)) + ) + ) + ); +} diff --git a/packages/mcp-server/src/persistence.ts b/packages/mcp-server/src/persistence.ts new file mode 100644 index 00000000..25c492cf --- /dev/null +++ b/packages/mcp-server/src/persistence.ts @@ -0,0 +1,48 @@ +import * as fs from "fs"; +import * as path from "path"; +import { WorkspaceContext } from "./types"; + +export class OutputPathError extends Error {} + +export async function writeTourAtomic( + ctx: WorkspaceContext, + relativeTarget: string, + content: string +): Promise { + const target = path.resolve(ctx.root, relativeTarget); + const directory = path.dirname(target); + await fs.promises.mkdir(directory, { recursive: true }); + + let realDirectory: string; + try { + realDirectory = fs.realpathSync(directory); + } catch (error) { + throw new OutputPathError( + `Unable to resolve the output directory: ${(error as Error).message}` + ); + } + const relative = path.relative(ctx.realRoot, realDirectory); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new OutputPathError( + `${relativeTarget} resolves outside the workspace root` + ); + } + + const tempFile = path.join( + directory, + `.${path.basename(target)}.tmp-${process.pid}-${Date.now()}` + ); + const handle = await fs.promises.open(tempFile, "w"); + try { + await handle.writeFile(content, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await fs.promises.rename(tempFile, target); + } catch (error) { + await fs.promises.unlink(tempFile).catch(() => undefined); + throw error; + } +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 00000000..964182a4 --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,451 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createContext } from "./context"; +import { + changedFiles, + committedDiffIsEmpty, + currentBranchName, + currentHeadSha, + isGitRepository, + mergeBase, + normalizeSlashes, + uncommittedChanges, + workspacePrefix, +} from "./git"; +import { OutputPathError, writeTourAtomic } from "./persistence"; +import { validateCodetourTour } from "./codetour-schema"; +import { + ErrorResult, + Issue, + SuccessResult, + TourFile, + TourStep, + Warning, + WorkspaceContext, +} from "./types"; +import { + MAX_RECOMMENDED_STEPS, + validateChangesParams, + validateProjectParams, + validateSteps, +} from "./validation"; +import packageJson from "../package.json"; + +const CODETOUR_SCHEMA_URI = "https://aka.ms/codetour-schema"; +const SERVER_NAME = "codetour-mcp"; +const SERVER_VERSION = packageJson.version; + +const PROJECT_TOUR_PATH = ".tours/project.tour"; +const CHANGES_TOUR_PATH = ".tours/changes.tour"; + +const PROJECT_TOUR_DESCRIPTION = + "Creates a CodeTour Project Tour that explains a codebase as a whole, persisted at " + + ".tours/project.tour (replacing any previously generated tour of the same kind). " + + "You provide the fully written content; the server only validates and persists it deterministically. " + + "A good Project Tour ideally covers: the project's purpose, its main entry points, its important " + + "components, and its main execution flows. " + + "Arguments: an optional title (defaults to \"Project Overview\"), an optional description, and a " + + "required non-empty steps array. Each step takes an optional title, a required Markdown description, " + + "and at most one locator: a file or a directory (workspace-relative paths). A step may also target a " + + "line, a unique stable pattern, or a selection, but only together with a file; line and pattern are " + + "mutually exclusive. Prefer a unique stable pattern over a line number so the anchor resists file " + + "evolution, and use a line only as a fallback. Steps without any locator are allowed for general " + + "context. Every anchor is " + + "validated against the real workspace state, and all validation errors are reported in a single " + + "response. On failure, the previous tour file is preserved."; + +const CHANGES_TOUR_DESCRIPTION = + "Creates a CodeTour Changes Tour that explains the committed changes on the current branch since it " + + "diverged from a base ref, persisted at .tours/changes.tour (replacing any previously generated tour " + + "of the same kind). You provide the fully written content; the server only validates and persists it " + + "deterministically. A good Changes Tour ideally covers: the intent of the changes, the major " + + "modifications, their impact, and the relevant tests. " + + "Arguments: base (required Git ref), head (required full 40-character SHA of the analyzed commit, " + + "which must equal the current HEAD), includeUncommitted (optional boolean, default false), an optional " + + "title (defaults to \"Changes on \"), an optional description, and a required non-empty steps " + + "array. Steps follow the same rules as the Project Tour (prefer a unique stable pattern over a line " + + "number); steps may anchor unchanged files when they " + + "provide essential context, and deleted files must be explained with steps that have no locator. " + + "Uncommitted changes are excluded by default and reported as a warning; pass includeUncommitted to " + + "include them explicitly. The description is automatically enriched with the base, merge-base and " + + "head. On failure, the previous tour file is preserved."; + +const warningSchema = z.object({ + code: z.string(), + message: z.string(), +}); +const issueSchema = z.object({ + path: z.string(), + message: z.string(), +}); +const toolResultSchema = z.object({ + status: z.enum(["created", "error"]), + path: z.string().optional(), + stepCount: z.number().int().nonnegative().optional(), + warnings: z.array(warningSchema).optional(), + code: z.string().optional(), + message: z.string().optional(), + issues: z.array(issueSchema).optional(), +}); + +const projectToolInputSchema = z + .object({ + title: z.unknown().optional(), + description: z.unknown().optional(), + steps: z.unknown().optional(), + }) + .passthrough(); + +const changesToolInputSchema = z + .object({ + title: z.unknown().optional(), + description: z.unknown().optional(), + steps: z.unknown().optional(), + base: z.unknown().optional(), + head: z.unknown().optional(), + includeUncommitted: z.unknown().optional(), + }) + .passthrough(); + +export function createServer(workspaceRoot: string): McpServer { + const ctx = createContext(workspaceRoot); + const server = new McpServer( + { name: SERVER_NAME, version: SERVER_VERSION }, + { capabilities: { tools: {} } } + ); + + server.registerTool( + "create_project_tour", + { + description: PROJECT_TOUR_DESCRIPTION, + inputSchema: projectToolInputSchema, + outputSchema: toolResultSchema, + }, + (args) => handleCreateProjectTour(ctx, args) + ); + + server.registerTool( + "create_changes_tour", + { + description: CHANGES_TOUR_DESCRIPTION, + inputSchema: changesToolInputSchema, + outputSchema: toolResultSchema, + }, + (args) => handleCreateChangesTour(ctx, args) + ); + + return server; +} + +async function handleCreateProjectTour( + ctx: WorkspaceContext, + args: unknown +): Promise { + const rawSteps = extractSteps(args); + if (rawSteps === undefined || (Array.isArray(rawSteps) && rawSteps.length === 0)) { + return errorResponse("TOUR_STEPS_REQUIRED", "A tour requires at least one step."); + } + + const { params, issues: paramIssues } = validateProjectParams(args); + const allIssues = [...paramIssues]; + let steps: TourStep[] | undefined; + if (Array.isArray(rawSteps)) { + const validated = validateSteps(rawSteps, ctx); + steps = validated.steps; + allIssues.push(...validated.issues); + } + if (!params || allIssues.length > 0) { + return errorResponse( + "INVALID_PROPOSAL", + "The create_project_tour arguments are invalid.", + allIssues + ); + } + const finalSteps = steps as TourStep[]; + + const warnings = stepLimitWarnings(finalSteps); + + const tour: TourFile = { + $schema: CODETOUR_SCHEMA_URI, + title: params.title ?? "Project Overview", + ...(params.description !== undefined ? { description: params.description } : {}), + steps: finalSteps, + }; + + const writeFailure = await persistTour(ctx, PROJECT_TOUR_PATH, tour); + if (writeFailure) { + return writeFailure; + } + + return successResponse( + PROJECT_TOUR_PATH, + finalSteps.length, + warnings, + `Created Project Tour at ${PROJECT_TOUR_PATH} with ${finalSteps.length} step(s).` + ); +} + +async function handleCreateChangesTour( + ctx: WorkspaceContext, + args: unknown +): Promise { + const rawSteps = extractSteps(args); + if (rawSteps === undefined || (Array.isArray(rawSteps) && rawSteps.length === 0)) { + return errorResponse("TOUR_STEPS_REQUIRED", "A tour requires at least one step."); + } + + const { params, issues: paramIssues } = validateChangesParams(args); + const allIssues = [...paramIssues]; + let steps: TourStep[] | undefined; + if (Array.isArray(rawSteps)) { + const validated = validateSteps(rawSteps, ctx); + steps = validated.steps; + allIssues.push(...validated.issues); + } + if (!params || allIssues.length > 0) { + return errorResponse( + "INVALID_PROPOSAL", + "The create_changes_tour arguments are invalid.", + allIssues + ); + } + + const base = params.base as string; + const head = params.head as string; + const includeUncommitted = params.includeUncommitted === true; + + if (!(await isGitRepository(ctx))) { + return errorResponse( + "GIT_REPOSITORY_REQUIRED", + "create_changes_tour requires a Git repository, but the workspace root is not inside one." + ); + } + + const currentHead = await currentHeadSha(ctx); + if (currentHead === null) { + return errorResponse( + "STALE_HEAD", + "The repository has no commits, so there is no HEAD to analyze." + ); + } + if (head !== currentHead) { + return errorResponse( + "STALE_HEAD", + `The provided head ${head} does not match the current HEAD (${currentHead}). The analysis is stale; re-run it against the current HEAD.` + ); + } + + let mergeBaseSha: string; + try { + mergeBaseSha = await mergeBase(ctx, base, head); + } catch (error) { + return errorResponse( + "INVALID_BASE_REF", + `Unable to compute the merge-base between ${base} and ${head}: ${(error as Error).message}` + ); + } + + const uncommitted = await uncommittedChanges(ctx); + if ( + (await committedDiffIsEmpty(ctx, mergeBaseSha, head)) && + (!includeUncommitted || uncommitted.length === 0) + ) { + return errorResponse( + "NO_CHANGES", + `No committed changes between the merge-base of ${base} (${mergeBaseSha}) and ${head}. The previous tour file was preserved.` + ); + } + + const warnings: Warning[] = []; + if (includeUncommitted) { + warnings.push({ + code: "UNCOMMITTED_CHANGES_INCLUDED", + message: + "Uncommitted changes were explicitly included; the tour describes a local state that is not reproducible from Git.", + }); + } else if (uncommitted.length > 0) { + warnings.push({ + code: "UNCOMMITTED_CHANGES_EXCLUDED", + message: `${uncommitted.length} uncommitted change(s) were excluded from the analysis (staged, unstaged or untracked).`, + }); + } + + const finalSteps = steps as TourStep[]; + const warningsFromStepLimit = stepLimitWarnings(finalSteps); + warnings.push(...warningsFromStepLimit); + + const changedInWorkspace = await changedFilesInWorkspace(ctx, mergeBaseSha, head); + if (includeUncommitted) { + for (const entry of uncommitted) { + if (!changedInWorkspace.includes(entry.path)) { + changedInWorkspace.push(entry.path); + } + } + } + if ( + changedInWorkspace.length > 0 && + !finalSteps.some( + (step) => + step.file !== undefined && + changedInWorkspace.includes(normalizeSlashes(step.file)) + ) + ) { + warnings.push({ + code: "NO_CHANGED_FILE_ANCHOR", + message: + "No step anchors a file modified by these changes; consider anchoring steps on changed files.", + }); + } + + const title = params.title ?? (await defaultChangesTitle(ctx, head)); + const provenance = includeUncommitted + ? `Generated from the merge-base of \`${base}\` (\`${mergeBaseSha}\`) to \`${head}\`, including uncommitted changes (non-reproducible local state).` + : `Generated from the merge-base of \`${base}\` (\`${mergeBaseSha}\`) to \`${head}\`.`; + const description = + params.description !== undefined + ? `${params.description}\n\n${provenance}` + : provenance; + + const tour: TourFile = { + $schema: CODETOUR_SCHEMA_URI, + title, + description, + ...(!includeUncommitted ? { ref: head } : {}), + steps: finalSteps, + }; + + const writeFailure = await persistTour(ctx, CHANGES_TOUR_PATH, tour); + if (writeFailure) { + return writeFailure; + } + + return successResponse( + CHANGES_TOUR_PATH, + finalSteps.length, + warnings, + `Created Changes Tour at ${CHANGES_TOUR_PATH} with ${finalSteps.length} step(s) for head ${head} (base ${base}).` + ); +} + +function stepLimitWarnings(steps: TourStep[]): Warning[] { + if (steps.length <= MAX_RECOMMENDED_STEPS) { + return []; + } + return [ + { + code: "STEP_LIMIT_EXCEEDED", + message: `The tour has ${steps.length} steps; the recommended maximum is ${MAX_RECOMMENDED_STEPS}.`, + }, + ]; +} + +async function persistTour( + ctx: WorkspaceContext, + relativePath: string, + tour: TourFile +): Promise { + const schemaResult = validateCodetourTour(tour); + if (!schemaResult) { + return errorResponse( + "SCHEMA_VALIDATION_FAILED", + "The generated tour did not validate against the CodeTour schema.", + [] + ); + } + try { + await writeTourAtomic(ctx, relativePath, serializeTour(tour)); + } catch (error) { + if (error instanceof OutputPathError) { + return errorResponse("OUTPUT_PATH_ESCAPES_WORKSPACE", error.message); + } + throw error; + } + return null; +} + +function extractSteps(args: unknown): unknown { + if (typeof args !== "object" || args === null || Array.isArray(args)) { + return undefined; + } + return (args as Record).steps; +} + +async function changedFilesInWorkspace( + ctx: WorkspaceContext, + mergeBaseSha: string, + head: string +): Promise { + const files = await changedFiles(ctx, mergeBaseSha, head); + const prefix = normalizeSlashes(await workspacePrefix(ctx)); + return files + .map(normalizeSlashes) + .filter((file) => file.startsWith(prefix)) + .map((file) => file.slice(prefix.length)); +} + +async function defaultChangesTitle( + ctx: WorkspaceContext, + head: string +): Promise { + const branch = await currentBranchName(ctx); + if (branch && branch !== "HEAD") { + return `Changes on ${branch}`; + } + return `Changes at ${head.slice(0, 7)}`; +} + +function serializeTour(tour: TourFile): string { + return JSON.stringify(tour, null, 2) + "\n"; +} + +interface ToolResponse { + [key: string]: unknown; + content: { type: "text"; text: string }[]; + structuredContent: Record; + isError?: boolean; +} + +function successResponse( + relativePath: string, + stepCount: number, + warnings: Warning[], + message: string +): ToolResponse { + const result: SuccessResult = { + status: "created", + path: relativePath, + stepCount, + warnings, + }; + const text = [ + message, + ...warnings.map((warning) => `Warning (${warning.code}): ${warning.message}`), + ].join("\n"); + return { + content: [{ type: "text", text }], + structuredContent: result as unknown as Record, + }; +} + +function errorResponse( + code: string, + message: string, + issues?: Issue[] +): ToolResponse { + const result: ErrorResult = { + status: "error", + code, + message, + ...(issues && issues.length > 0 ? { issues } : {}), + }; + const text = + issues && issues.length > 0 + ? `Error (${code}): ${message}\n` + + issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n") + : `Error (${code}): ${message}`; + return { + content: [{ type: "text", text }], + structuredContent: result as unknown as Record, + isError: true, + }; +} diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts new file mode 100644 index 00000000..733dcafd --- /dev/null +++ b/packages/mcp-server/src/types.ts @@ -0,0 +1,70 @@ +export interface Position { + line: number; + character: number; +} + +export interface Selection { + start: Position; + end: Position; +} + +export interface TourStep { + title?: string; + description: string; + file?: string; + directory?: string; + line?: number; + pattern?: string; + selection?: Selection; +} + +export interface ProjectParams { + title?: string; + description?: string; + steps?: unknown[]; +} + +export interface ChangesParams extends ProjectParams { + base?: string; + head?: string; + includeUncommitted?: boolean; +} + +export interface TourFile { + $schema?: string; + title: string; + description?: string; + ref?: string; + steps: TourStep[]; +} + +export interface Issue { + path: string; + message: string; +} + +export interface Warning { + code: string; + message: string; +} + +export interface SuccessResult { + status: "created"; + path: string; + stepCount: number; + warnings: Warning[]; +} + +export interface ErrorResult { + status: "error"; + code: string; + message: string; + issues?: Issue[]; +} + +export type ToolResult = SuccessResult | ErrorResult; + +export interface WorkspaceContext { + root: string; + realRoot: string; +} diff --git a/packages/mcp-server/src/validation.ts b/packages/mcp-server/src/validation.ts new file mode 100644 index 00000000..7af0056b --- /dev/null +++ b/packages/mcp-server/src/validation.ts @@ -0,0 +1,470 @@ +import * as fs from "fs"; +import * as path from "path"; +import { + ChangesParams, + Issue, + Position, + ProjectParams, + Selection, + TourStep, + WorkspaceContext, +} from "./types"; + +export const STEP_FIELDS = [ + "title", + "description", + "file", + "directory", + "line", + "pattern", + "selection", +] as const; + +export const MAX_RECOMMENDED_STEPS = 15; + +const FORBIDDEN_URI_SCHEME = + /(?:command|file|vscode|vscode-insiders|javascript):/i; + +const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/; + +type RawObject = Record; + +function isPlainObject(value: unknown): value is RawObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + +function isOptionalString( + raw: RawObject, + key: string, + errorPath: string, + issues: Issue[] +): string | undefined { + const value = raw[key]; + if (value === undefined) { + return undefined; + } + if (typeof value !== "string") { + issues.push({ path: errorPath, message: "must be a string" }); + return undefined; + } + return value; +} + +function reportUnknownFields( + raw: RawObject, + allowed: readonly string[], + errorPath: string, + issues: Issue[] +): void { + for (const key of Object.keys(raw)) { + if (!allowed.includes(key)) { + issues.push({ + path: errorPath === "$" ? key : `${errorPath}.${key}`, + message: + "unknown field (V1 tours only allow title, description, file, directory, line, pattern and selection)", + }); + } + } +} + +const PROJECT_PARAM_FIELDS = ["title", "description", "steps"] as const; +const CHANGES_PARAM_FIELDS = [ + "title", + "description", + "steps", + "base", + "head", + "includeUncommitted", +] as const; + +export function validateProjectParams( + raw: unknown +): { params?: ProjectParams; issues: Issue[] } { + const issues: Issue[] = []; + if (!isPlainObject(raw)) { + issues.push({ path: "$", message: "the arguments must be an object" }); + return { issues }; + } + reportUnknownFields(raw, PROJECT_PARAM_FIELDS, "$", issues); + const params = validateCommonParams(raw, issues); + return { params, issues }; +} + +export function validateChangesParams( + raw: unknown +): { params?: ChangesParams; issues: Issue[] } { + const issues: Issue[] = []; + if (!isPlainObject(raw)) { + issues.push({ path: "$", message: "the arguments must be an object" }); + return { issues }; + } + reportUnknownFields(raw, CHANGES_PARAM_FIELDS, "$", issues); + const params = validateCommonParams(raw, issues) as ChangesParams; + if (typeof raw.base !== "string" || raw.base.trim() === "") { + issues.push({ path: "base", message: "is required and must be a non-empty string" }); + } else { + params.base = raw.base; + } + if (typeof raw.head !== "string") { + issues.push({ path: "head", message: "is required and must be the full 40-character commit SHA" }); + } else if (!FULL_SHA_PATTERN.test(raw.head)) { + issues.push({ path: "head", message: "must be the full 40-character commit SHA" }); + } else { + params.head = raw.head; + } + if (raw.includeUncommitted !== undefined) { + if (typeof raw.includeUncommitted !== "boolean") { + issues.push({ path: "includeUncommitted", message: "must be a boolean" }); + } else { + params.includeUncommitted = raw.includeUncommitted; + } + } + return { params, issues }; +} + +function validateCommonParams(raw: RawObject, issues: Issue[]): ProjectParams { + const params: ProjectParams = {}; + const title = isOptionalString(raw, "title", "title", issues); + if (title !== undefined) { + params.title = title; + } + const description = isOptionalString(raw, "description", "description", issues); + if (description !== undefined) { + if (FORBIDDEN_URI_SCHEME.test(description)) { + issues.push({ + path: "description", + message: + "contains a forbidden URI scheme (command:, file:, vscode:, vscode-insiders:, javascript:)", + }); + } + params.description = description; + } + if (raw.steps === undefined) { + issues.push({ path: "steps", message: "is required" }); + } else if (!Array.isArray(raw.steps)) { + issues.push({ path: "steps", message: "must be an array" }); + } else { + params.steps = raw.steps; + } + return params; +} + +export function validateSteps( + rawSteps: unknown[], + ctx: WorkspaceContext +): { steps?: TourStep[]; issues: Issue[] } { + const allIssues: Issue[] = []; + const steps: TourStep[] = []; + for (let index = 0; index < rawSteps.length; index++) { + const { step, issues } = validateStep(rawSteps[index], index, ctx); + allIssues.push(...issues); + if (step) { + steps.push(step); + } + } + if (allIssues.length > 0) { + return { issues: allIssues }; + } + return { steps, issues: [] }; +} + +function validateStep( + raw: unknown, + index: number, + ctx: WorkspaceContext +): { step?: TourStep; issues: Issue[] } { + const issues: Issue[] = []; + const base = `steps[${index}]`; + if (!isPlainObject(raw)) { + issues.push({ path: base, message: "must be an object" }); + return { issues }; + } + reportUnknownFields(raw, STEP_FIELDS, base, issues); + + const description = isOptionalString(raw, "description", `${base}.description`, issues); + if (description === undefined && raw.description === undefined) { + issues.push({ path: `${base}.description`, message: "is required and must be a string" }); + } + + const title = isOptionalString(raw, "title", `${base}.title`, issues); + + const file = isOptionalString(raw, "file", `${base}.file`, issues); + const directory = isOptionalString(raw, "directory", `${base}.directory`, issues); + + if (file !== undefined && directory !== undefined) { + issues.push({ path: base, message: "a step cannot have both a file and a directory" }); + } + + let line: number | undefined; + if (raw.line !== undefined) { + if (!isPositiveInteger(raw.line)) { + issues.push({ path: `${base}.line`, message: "must be a positive integer" }); + } else { + line = raw.line; + } + if (file === undefined) { + issues.push({ path: `${base}.line`, message: "is only valid together with a file" }); + } + } + + let pattern: string | undefined; + if (raw.pattern !== undefined) { + if (typeof raw.pattern !== "string") { + issues.push({ path: `${base}.pattern`, message: "must be a string" }); + } else { + pattern = raw.pattern; + } + if (file === undefined) { + issues.push({ path: `${base}.pattern`, message: "is only valid together with a file" }); + } + } + + if (line !== undefined && pattern !== undefined) { + issues.push({ path: base, message: "line and pattern are mutually exclusive" }); + } + + let selection: Selection | undefined; + if (raw.selection !== undefined) { + selection = validateSelection(raw.selection, `${base}.selection`, issues); + if (file === undefined) { + issues.push({ path: `${base}.selection`, message: "is only valid together with a file" }); + } + } + + if (description !== undefined && FORBIDDEN_URI_SCHEME.test(description)) { + issues.push({ + path: `${base}.description`, + message: + "contains a forbidden URI scheme (command:, file:, vscode:, vscode-insiders:, javascript:)", + }); + } + + let fileContent: string | undefined; + let fileAnchorOk = true; + if (file !== undefined) { + const anchor = checkAnchor(ctx, file, "file", `${base}.file`, issues); + if (anchor.ok && anchor.realPath !== undefined) { + try { + fileContent = fs.readFileSync(anchor.realPath, "utf8"); + } catch { + issues.push({ path: `${base}.file`, message: "could not be read" }); + } + } + fileAnchorOk = anchor.ok; + } + if (directory !== undefined) { + checkAnchor(ctx, directory, "directory", `${base}.directory`, issues); + } + + if (fileContent === undefined && !fileAnchorOk) { + for (const field of ["line", "pattern", "selection"] as const) { + if (raw[field] !== undefined) { + issues.push({ + path: `${base}.${field}`, + message: "cannot be validated because the file anchor is invalid", + }); + } + } + } + + if (line !== undefined && fileContent !== undefined) { + const lineCount = fileContent.split("\n").length; + if (line > lineCount) { + issues.push({ + path: `${base}.line`, + message: `is out of range: the file has ${lineCount} line(s)`, + }); + } + } + + if (pattern !== undefined && fileContent !== undefined) { + let regex: RegExp; + try { + regex = new RegExp(pattern); + } catch (error) { + issues.push({ + path: `${base}.pattern`, + message: `is not a valid regular expression: ${(error as Error).message}`, + }); + regex = undefined as unknown as RegExp; + } + if (regex) { + const matches = fileContent.match(new RegExp(regex.source, "g")) ?? []; + if (matches.length !== 1) { + issues.push({ + path: `${base}.pattern`, + message: `must match exactly one occurrence in the file (matched ${matches.length})`, + }); + } + } + } + + if (selection && fileContent !== undefined) { + validateSelectionBounds(selection, fileContent, base, issues); + } + + if (issues.length > 0) { + return { issues }; + } + return { + step: { + title, + description: description as string, + file, + directory, + line, + pattern, + selection, + }, + issues: [], + }; +} + +function validateSelection( + raw: unknown, + base: string, + issues: Issue[] +): Selection | undefined { + if (!isPlainObject(raw)) { + issues.push({ path: base, message: "must be an object with start and end" }); + return undefined; + } + reportUnknownFields(raw, ["start", "end"], base, issues); + const start = validatePosition(raw.start, `${base}.start`, issues); + const end = validatePosition(raw.end, `${base}.end`, issues); + if (!start || !end) { + return undefined; + } + if ( + start.line > end.line || + (start.line === end.line && start.character > end.character) + ) { + issues.push({ path: base, message: "the start position must be before the end position" }); + return undefined; + } + return { start, end }; +} + +function validatePosition( + raw: unknown, + base: string, + issues: Issue[] +): Position | undefined { + if (!isPlainObject(raw)) { + issues.push({ path: base, message: "must be an object with line and character" }); + return undefined; + } + reportUnknownFields(raw, ["line", "character"], base, issues); + let line: number | undefined; + if (!isPositiveInteger(raw.line)) { + issues.push({ path: `${base}.line`, message: "is required and must be a positive integer" }); + } else { + line = raw.line; + } + let character: number | undefined; + if (!isPositiveInteger(raw.character)) { + issues.push({ path: `${base}.character`, message: "is required and must be a positive integer" }); + } else { + character = raw.character; + } + if (line === undefined || character === undefined) { + return undefined; + } + return { line, character }; +} + +function validateSelectionBounds( + selection: Selection, + fileContent: string, + base: string, + issues: Issue[] +): void { + const lines = fileContent.split("\n"); + const maxLine = lines.length; + if (selection.start.line > maxLine) { + issues.push({ + path: `${base}.selection.start.line`, + message: `is out of range: the file has ${maxLine} line(s)`, + }); + } else { + const maxCharacter = lines[selection.start.line - 1].length + 1; + if (selection.start.character > maxCharacter) { + issues.push({ + path: `${base}.selection.start.character`, + message: `is out of range: line ${selection.start.line} has ${maxCharacter} character slot(s)`, + }); + } + } + if (selection.end.line > maxLine) { + issues.push({ + path: `${base}.selection.end.line`, + message: `is out of range: the file has ${maxLine} line(s)`, + }); + } else { + const maxCharacter = lines[selection.end.line - 1].length + 1; + if (selection.end.character > maxCharacter) { + issues.push({ + path: `${base}.selection.end.character`, + message: `is out of range: line ${selection.end.line} has ${maxCharacter} character slot(s)`, + }); + } + } +} + +interface AnchorResult { + ok: boolean; + realPath?: string; +} + +function checkAnchor( + ctx: WorkspaceContext, + value: string, + kind: "file" | "directory", + errorPath: string, + issues: Issue[] +): AnchorResult { + if (value.trim() === "") { + issues.push({ path: errorPath, message: "must not be empty" }); + return { ok: false }; + } + if (path.isAbsolute(value)) { + issues.push({ path: errorPath, message: "must be relative to the workspace root" }); + return { ok: false }; + } + const target = path.resolve(ctx.root, value); + let realPath: string; + try { + realPath = fs.realpathSync(target); + } catch { + issues.push({ path: errorPath, message: "does not exist in the workspace" }); + return { ok: false }; + } + const relative = path.relative(ctx.realRoot, realPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + issues.push({ + path: errorPath, + message: "resolves outside the workspace root (symlinks escaping the workspace are not allowed)", + }); + return { ok: false }; + } + let stat: fs.Stats; + try { + stat = fs.statSync(realPath); + } catch { + issues.push({ path: errorPath, message: "does not exist in the workspace" }); + return { ok: false }; + } + if (kind === "file" && !stat.isFile()) { + issues.push({ path: errorPath, message: "is not a file" }); + return { ok: false }; + } + if (kind === "directory" && !stat.isDirectory()) { + issues.push({ path: errorPath, message: "is not a directory" }); + return { ok: false }; + } + return { ok: true, realPath }; +} diff --git a/packages/mcp-server/test/helpers/test-utils.ts b/packages/mcp-server/test/helpers/test-utils.ts new file mode 100644 index 00000000..141843bc --- /dev/null +++ b/packages/mcp-server/test/helpers/test-utils.ts @@ -0,0 +1,147 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { validateCodetourTour } from "../../src/codetour-schema"; +import { git } from "../../src/git"; + +export { git }; + +export const CLI_PATH = path.join(__dirname, "..", "..", "src", "cli.js"); + +export interface ToolResponse { + isError: boolean; + text: string; + structured: Record; +} + +export async function startServer(workspaceRoot: string): Promise { + const client = new Client( + { name: "codetour-mcp-test-client", version: "0.0.0" }, + { capabilities: {} } + ); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [CLI_PATH, "--workspace-root", workspaceRoot], + }); + await client.connect(transport); + return client; +} + +export async function withServer( + root: string, + run: (client: Client) => Promise +): Promise { + const client = await startServer(root); + try { + return await run(client); + } finally { + await stopServer(client); + } +} + +export async function callTool( + client: Client, + name: string, + args: unknown +): Promise { + const result = (await client.callTool({ + name, + arguments: args as Record, + })) as unknown as { + content?: Array<{ type: string; text?: string }>; + isError?: boolean; + structuredContent?: Record; + }; + const text = (result.content ?? []) + .filter((item) => item.type === "text" && item.text !== undefined) + .map((item) => item.text as string) + .join("\n"); + return { + isError: result.isError ?? false, + text, + structured: result.structuredContent ?? {}, + }; +} + +export async function stopServer(client: Client): Promise { + await client.close(); +} + +export function tempDir(prefix = "codetour-mcp-test-"): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +export function rmrf(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +export function writeFile(workspaceRoot: string, relativePath: string, content: string): void { + const target = path.join(workspaceRoot, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +export async function initGitRepo(dir: string, branch = "main"): Promise { + await git(["init", "-b", branch], dir); + await git(["config", "user.email", "test@example.com"], dir); + await git(["config", "user.name", "Test User"], dir); + await git(["config", "commit.gpgsign", "false"], dir); + await git(["config", "tag.gpgsign", "false"], dir); +} + +export async function commitFile( + dir: string, + relativePath: string, + content: string, + message: string +): Promise { + const target = path.join(dir, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + await git(["add", "-A"], dir); + await git(["commit", "-m", message], dir); +} + +export async function headSha(dir: string): Promise { + const result = await git(["rev-parse", "HEAD"], dir); + return result.stdout.trim(); +} + +export function readTourFile( + workspaceRoot: string, + relativePath: string +): Record { + return JSON.parse( + fs.readFileSync(path.join(workspaceRoot, relativePath), "utf8") + ); +} + +export function tourFileValidAgainstSchema( + workspaceRoot: string, + relativePath: string +): boolean { + const content = readTourFile(workspaceRoot, relativePath); + return validateCodetourTour(content) as boolean; +} + +export function structuredCode(response: ToolResponse): string { + return (response.structured.code ?? "") as string; +} + +export function structuredIssues(response: ToolResponse): Array<{ path: string; message: string }> { + return (response.structured.issues ?? []) as Array<{ path: string; message: string }>; +} + +export function structuredWarnings(response: ToolResponse): Array<{ code: string; message: string }> { + return (response.structured.warnings ?? []) as Array<{ code: string; message: string }>; +} + +export function warningCodes(response: ToolResponse): string[] { + return structuredWarnings(response).map((warning) => warning.code); +} + +export function issuePaths(response: ToolResponse): string[] { + return structuredIssues(response).map((issue) => issue.path); +} diff --git a/packages/mcp-server/test/integration/changes-tour.test.ts b/packages/mcp-server/test/integration/changes-tour.test.ts new file mode 100644 index 00000000..d01be623 --- /dev/null +++ b/packages/mcp-server/test/integration/changes-tour.test.ts @@ -0,0 +1,503 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { + callTool, + commitFile, + git, + headSha, + initGitRepo, + readTourFile, + rmrf, + structuredCode, + tempDir, + tourFileValidAgainstSchema, + warningCodes, + withServer, + writeFile, +} from "../helpers/test-utils"; + +async function setupRepo(): Promise<{ root: string; baseSha: string; head: string }> { + const root = tempDir(); + await initGitRepo(root); + await commitFile(root, "base.txt", "base content\n", "add base file"); + const baseSha = await headSha(root); + await git(["checkout", "-b", "feature"], root); + await commitFile(root, "feature.txt", "feature content\n", "add feature file"); + const head = await headSha(root); + return { root, baseSha, head }; +} + +test("creates a changes tour with the exact head SHA as ref", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + description: "Explains the feature branch.", + steps: [ + { description: "Intent of the change." }, + { description: "The new file.", file: "feature.txt", line: 1 }, + ], + }); + assert.equal(response.isError, false); + assert.equal(response.structured.status, "created"); + assert.equal(response.structured.path, ".tours/changes.tour"); + assert.equal(response.structured.stepCount, 2); + assert.deepEqual(response.structured.warnings, []); + }); + const tour = readTourFile(root, ".tours/changes.tour"); + assert.equal(tour.ref, head); + assert.equal(tour.title, "Changes on feature"); + const description = tour.description as string; + assert.ok(description.includes("Explains the feature branch.")); + assert.ok(description.includes(baseSha)); + assert.ok(description.includes(head)); + assert.ok(tourFileValidAgainstSchema(root, ".tours/changes.tour")); + } finally { + rmrf(root); + } +}); + +test("uses the provided title when given", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + title: "Custom Title", + steps: [{ description: "d", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + }); + assert.equal(readTourFile(root, ".tours/changes.tour").title, "Custom Title"); + } finally { + rmrf(root); + } +}); + +test("fails with STALE_HEAD when HEAD moved since the analysis", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await commitFile(root, "extra.txt", "extra\n", "extra commit"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [{ description: "d" }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "STALE_HEAD"); + }); + } finally { + rmrf(root); + } +}); + +test("fails with STALE_HEAD when the head argument is not the current HEAD", async () => { + const { root, baseSha } = await setupRepo(); + try { + const other = baseSha; + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head: other, + steps: [{ description: "d" }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "STALE_HEAD"); + }); + } finally { + rmrf(root); + } +}); + +test("fails with GIT_REPOSITORY_REQUIRED outside a git repository", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: "main", + head: "a".repeat(40), + steps: [{ description: "d" }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "GIT_REPOSITORY_REQUIRED"); + }); + } finally { + rmrf(root); + } +}); + +test("fails with NO_CHANGES when the range has no committed changes", async () => { + const { root, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: head, + head, + steps: [{ description: "d" }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "NO_CHANGES"); + }); + } finally { + rmrf(root); + } +}); + +test("fails with INVALID_BASE_REF when the merge-base cannot be computed", async () => { + const { root, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: "no-such-branch", + head, + steps: [{ description: "d" }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_BASE_REF"); + }); + } finally { + rmrf(root); + } +}); + +test("keeps the previous tour when NO_CHANGES occurs", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const first = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [{ description: "Original.", file: "feature.txt" }], + }); + assert.equal(first.isError, false); + const second = await callTool(client, "create_changes_tour", { + base: head, + head, + steps: [{ description: "Should not replace." }], + }); + assert.equal(second.isError, true); + assert.equal(structuredCode(second), "NO_CHANGES"); + }); + assert.equal( + readTourFile(root, ".tours/changes.tour").ref, + head + ); + } finally { + rmrf(root); + } +}); + +test("warns when uncommitted changes are excluded", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, "feature.txt", "modified locally\n"); + writeFile(root, "untracked.txt", "new local file\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [{ description: "d", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_EXCLUDED")); + }); + assert.equal(readTourFile(root, ".tours/changes.tour").ref, head); + } finally { + rmrf(root); + } +}); + +test("ignores the reserved tour files when detecting a dirty workspace", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, ".tours/project.tour", "{ generated earlier }"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [{ description: "d", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(!warningCodes(response).includes("UNCOMMITTED_CHANGES_EXCLUDED")); + }); + } finally { + rmrf(root); + } +}); + +test("includes uncommitted changes explicitly and drops the ref", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, "work-in-progress.txt", "local work\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + includeUncommitted: true, + steps: [ + { description: "Local work.", file: "work-in-progress.txt" }, + ], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_INCLUDED")); + }); + const tour = readTourFile(root, ".tours/changes.tour"); + assert.equal(tour.ref, undefined); + assert.ok((tour.description as string).includes("non-reproducible")); + assert.ok(tourFileValidAgainstSchema(root, ".tours/changes.tour")); + } finally { + rmrf(root); + } +}); + +test("warns with NO_CHANGED_FILE_ANCHOR when no step anchors a changed file", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [{ description: "Only context, no anchors." }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + } finally { + rmrf(root); + } +}); + +test("does not warn when a step anchors a changed file", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [{ description: "The new file.", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(!warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + } finally { + rmrf(root); + } +}); + +test("allows deletion-only branches with content-only steps", async () => { + const root = tempDir(); + await initGitRepo(root); + await commitFile(root, "victim.txt", "to be deleted\n", "add victim"); + const baseSha = await headSha(root); + await git(["checkout", "-b", "cleanup"], root); + await git(["rm", "victim.txt"], root); + await git(["commit", "-m", "remove victim"], root); + const head = await headSha(root); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [{ description: "We removed victim.txt entirely." }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + assert.equal(readTourFile(root, ".tours/changes.tour").ref, head); + assert.ok(tourFileValidAgainstSchema(root, ".tours/changes.tour")); + } finally { + rmrf(root); + } +}); + +test("allows anchoring unchanged files for essential context", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [ + { description: "Context from the base file.", file: "base.txt" }, + { description: "The new file.", file: "feature.txt" }, + ], + }); + assert.equal(response.isError, false); + assert.ok(!warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + } finally { + rmrf(root); + } +}); + +test("aggregates step validation errors for a changes tour", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [ + { description: "d", file: "missing.txt" }, + { description: "d", file: "feature.txt", line: 99 }, + ], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + const issues = (response.structured.issues ?? []) as Array<{ path: string }>; + assert.ok(issues.some((issue) => issue.path === "steps[0].file")); + assert.ok(issues.some((issue) => issue.path === "steps[1].line")); + }); + } finally { + rmrf(root); + } +}); + +test("works when the workspace root is a subdirectory of the repository", async () => { + const root = tempDir(); + await initGitRepo(root); + await commitFile(root, "packages/app/main.ts", "export {};\n", "add app"); + await commitFile(root, "packages/app/util.ts", "export {};\n", "add util"); + const baseSha = await headSha(root); + await git(["checkout", "-b", "sub", "-q"], root); + await commitFile(root, "packages/app/new.ts", "export {};\n", "add new file"); + const head = await headSha(root); + const subRoot = `${root}/packages/app`; + try { + await withServer(subRoot, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [{ description: "The new file.", file: "new.ts" }], + }); + assert.equal(response.isError, false); + assert.ok(!warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + assert.ok(tourFileValidAgainstSchema(subRoot, ".tours/changes.tour")); + } finally { + rmrf(root); + } +}); + +test("warns when the tour exceeds fifteen steps for a changes tour", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + const steps = Array.from({ length: 16 }, (_, index) => ({ + description: `Step ${index + 1}.`, + })); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps, + }); + assert.equal(response.isError, false); + assert.equal(response.structured.stepCount, 16); + assert.ok(warningCodes(response).includes("STEP_LIMIT_EXCEEDED")); + }); + } finally { + rmrf(root); + } +}); + +test("warns when only staged changes are excluded", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, "staged.txt", "staged but not committed\n"); + await git(["add", "staged.txt"], root); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [{ description: "d", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_EXCLUDED")); + }); + } finally { + rmrf(root); + } +}); + +test("explains local work when only uncommitted changes exist", async () => { + const { root, head } = await setupRepo(); + try { + writeFile(root, "work-in-progress.txt", "local work\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: head, + head, + includeUncommitted: true, + steps: [{ description: "Local work.", file: "work-in-progress.txt" }], + }); + assert.equal(response.isError, false); + assert.equal(response.structured.status, "created"); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_INCLUDED")); + assert.ok(!warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + const tour = readTourFile(root, ".tours/changes.tour"); + assert.equal(tour.ref, undefined); + } finally { + rmrf(root); + } +}); + +test("counts uncommitted files as changed for NO_CHANGED_FILE_ANCHOR", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, "local-only.txt", "local work\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + includeUncommitted: true, + steps: [{ description: "Local work.", file: "local-only.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(!warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + } finally { + rmrf(root); + } +}); + +test("preserves the previous tour when the write fails", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const first = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [{ description: "Original.", file: "feature.txt" }], + }); + assert.equal(first.isError, false); + }); + const before = readTourFile(root, ".tours/changes.tour"); + const toursDir = `${root}/.tours`; + const fs = await import("node:fs"); + fs.chmodSync(toursDir, 0o555); + try { + await withServer(root, async (client) => { + const second = await callTool(client, "create_changes_tour", { + base: baseSha, + head, + steps: [{ description: "Should not land.", file: "feature.txt" }], + }); + assert.equal(second.isError, true); + }); + assert.deepEqual(readTourFile(root, ".tours/changes.tour"), before); + const leftovers = fs + .readdirSync(toursDir) + .filter((name) => name.includes(".tmp-")); + assert.deepEqual(leftovers, []); + } finally { + fs.chmodSync(toursDir, 0o755); + } + } finally { + rmrf(root); + } +}); diff --git a/packages/mcp-server/test/integration/project-tour.test.ts b/packages/mcp-server/test/integration/project-tour.test.ts new file mode 100644 index 00000000..1528b6db --- /dev/null +++ b/packages/mcp-server/test/integration/project-tour.test.ts @@ -0,0 +1,409 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { + callTool, + issuePaths, + readTourFile, + rmrf, + structuredCode, + structuredIssues, + tempDir, + tourFileValidAgainstSchema, + warningCodes, + withServer, + writeFile, +} from "../helpers/test-utils"; + +test("creates a project tour with the default title and no git ref", async () => { + const root = tempDir(); + try { + writeFile(root, "src/index.ts", "export const answer = 42;\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + description: "An overview of the project.", + steps: [ + { description: "Intro step." }, + { description: "The entry point.", file: "src/index.ts", line: 1 }, + ], + }); + assert.equal(response.isError, false); + assert.equal(response.structured.status, "created"); + assert.equal(response.structured.path, ".tours/project.tour"); + assert.equal(response.structured.stepCount, 2); + assert.deepEqual(response.structured.warnings, []); + }); + const tour = readTourFile(root, ".tours/project.tour"); + assert.equal(tour.title, "Project Overview"); + assert.equal(tour.description, "An overview of the project."); + assert.equal(tour.ref, undefined); + assert.equal(tour.$schema, "https://aka.ms/codetour-schema"); + assert.ok(tourFileValidAgainstSchema(root, ".tours/project.tour")); + } finally { + rmrf(root); + } +}); + +test("uses the provided title and omits the description when absent", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + title: "My Tour", + steps: [{ description: "Sole step." }], + }); + assert.equal(response.isError, false); + }); + const tour = readTourFile(root, ".tours/project.tour"); + assert.equal(tour.title, "My Tour"); + assert.equal(tour.description, undefined); + assert.ok(tourFileValidAgainstSchema(root, ".tours/project.tour")); + } finally { + rmrf(root); + } +}); + +test("works in a workspace that is not a git repository", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "No git here." }], + }); + assert.equal(response.isError, false); + assert.equal(response.structured.status, "created"); + }); + } finally { + rmrf(root); + } +}); + +test("rejects an empty steps array with TOUR_STEPS_REQUIRED", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "TOUR_STEPS_REQUIRED"); + }); + } finally { + rmrf(root); + } +}); + +test("rejects a missing steps array with TOUR_STEPS_REQUIRED", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", {}); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "TOUR_STEPS_REQUIRED"); + }); + } finally { + rmrf(root); + } +}); + +test("aggregates all step validation errors in one response", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "one\ntwo\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { description: "Missing file.", file: "nope.ts", line: 1 }, + { file: "a.ts" }, + ], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + const paths = issuePaths(response); + assert.ok(paths.includes("steps[0].file")); + assert.ok(paths.includes("steps[0].line")); + assert.ok(paths.includes("steps[1].description")); + }); + } finally { + rmrf(root); + } +}); + +test("keeps the previous tour when the new proposal is invalid", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const first = await callTool(client, "create_project_tour", { + title: "Original", + steps: [{ description: "Original step." }], + }); + assert.equal(first.isError, false); + + const second = await callTool(client, "create_project_tour", { + title: "Broken", + steps: [{ description: "Broken step.", file: "missing.ts" }], + }); + assert.equal(second.isError, true); + assert.equal(structuredCode(second), "INVALID_PROPOSAL"); + }); + const tour = readTourFile(root, ".tours/project.tour"); + assert.equal(tour.title, "Original"); + assert.ok(tourFileValidAgainstSchema(root, ".tours/project.tour")); + } finally { + rmrf(root); + } +}); + +test("replaces the previous tour atomically on success", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "one\n"); + await withServer(root, async (client) => { + await callTool(client, "create_project_tour", { + title: "First", + steps: [{ description: "First step." }], + }); + const second = await callTool(client, "create_project_tour", { + title: "Second", + steps: [{ description: "Second step.", file: "a.ts" }], + }); + assert.equal(second.isError, false); + }); + const tour = readTourFile(root, ".tours/project.tour"); + assert.equal(tour.title, "Second"); + assert.equal((tour.steps as unknown[]).length, 1); + assert.ok(tourFileValidAgainstSchema(root, ".tours/project.tour")); + } finally { + rmrf(root); + } +}); + +test("anchors a step on a directory", async () => { + const root = tempDir(); + try { + writeFile(root, "lib/util.ts", "export {};\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "The lib area.", directory: "lib" }], + }); + assert.equal(response.isError, false); + }); + assert.ok(tourFileValidAgainstSchema(root, ".tours/project.tour")); + } finally { + rmrf(root); + } +}); + +test("anchors a step on a unique pattern", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "const x = 1;\nconst y = 2;\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { description: "The y declaration.", file: "a.ts", pattern: "const y" }, + ], + }); + assert.equal(response.isError, false); + }); + } finally { + rmrf(root); + } +}); + +test("anchors a step on a valid selection", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "export const answer = 42;\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { + description: "The answer.", + file: "a.ts", + selection: { + start: { line: 1, character: 21 }, + end: { line: 1, character: 23 }, + }, + }, + ], + }); + assert.equal(response.isError, false); + }); + } finally { + rmrf(root); + } +}); + +test("rejects an out-of-range selection", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "short\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { + description: "Too long.", + file: "a.ts", + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 99 }, + }, + }, + ], + }); + assert.equal(response.isError, true); + assert.ok( + structuredIssues(response).some((issue) => + issue.path.includes("selection.end.character") + ) + ); + }); + } finally { + rmrf(root); + } +}); + +test("rejects an out-of-range line", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "one\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "Too far.", file: "a.ts", line: 10 }], + }); + assert.equal(response.isError, true); + assert.ok(issuePaths(response).includes("steps[0].line")); + }); + } finally { + rmrf(root); + } +}); + +test("warns without blocking when the tour exceeds fifteen steps", async () => { + const root = tempDir(); + try { + const steps = Array.from({ length: 16 }, (_, index) => ({ + description: `Step ${index + 1}.`, + })); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { steps }); + assert.equal(response.isError, false); + assert.equal(response.structured.stepCount, 16); + assert.ok(warningCodes(response).includes("STEP_LIMIT_EXCEEDED")); + }); + } finally { + rmrf(root); + } +}); + +test("rejects CodeTour commands, when expressions and uri fields", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + when: "true", + steps: [ + { description: "d", commands: ["workbench.action.quit"] }, + { description: "d", uri: "https://example.com" }, + ], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + const paths = issuePaths(response); + assert.ok(paths.includes("when")); + assert.ok(paths.includes("steps[0].commands")); + assert.ok(paths.includes("steps[1].uri")); + }); + } finally { + rmrf(root); + } +}); + +test("rejects active markdown URI schemes in descriptions", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { description: "Run this: [click](command:workbench.action.quit)" }, + ], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + assert.ok(issuePaths(response).includes("steps[0].description")); + }); + } finally { + rmrf(root); + } +}); + +test("allows ordinary https links and images in descriptions", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { + description: + "See [the docs](https://example.com/docs) and ![diagram](https://example.com/diagram.png).", + }, + ], + }); + assert.equal(response.isError, false); + }); + } finally { + rmrf(root); + } +}); + +test("rejects absolute paths as anchors", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "d", file: "/etc/passwd" }], + }); + assert.equal(response.isError, true); + assert.ok(issuePaths(response).includes("steps[0].file")); + }); + } finally { + rmrf(root); + } +}); + +test("rejects relative paths escaping the workspace root", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "one\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "d", file: "../escape.ts" }], + }); + assert.equal(response.isError, true); + assert.ok(issuePaths(response).includes("steps[0].file")); + }); + } finally { + rmrf(root); + } +}); + +test("rejects symlinks that escape the workspace root", async () => { + const root = tempDir(); + const outside = tempDir(); + try { + writeFile(outside, "secret.ts", "top secret\n"); + writeFile(root, "a.ts", "one\n"); + const fs = await import("node:fs"); + fs.symlinkSync(outside, `${root}/link-out`); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "d", file: "link-out/secret.ts" }], + }); + assert.equal(response.isError, true); + assert.ok(issuePaths(response).includes("steps[0].file")); + }); + } finally { + rmrf(root); + rmrf(outside); + } +}); diff --git a/packages/mcp-server/test/integration/security.test.ts b/packages/mcp-server/test/integration/security.test.ts new file mode 100644 index 00000000..009a16ab --- /dev/null +++ b/packages/mcp-server/test/integration/security.test.ts @@ -0,0 +1,113 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { + callTool, + rmrf, + structuredCode, + tempDir, + withServer, +} from "../helpers/test-utils"; + +test("rejects a file: scheme in a step description", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "Open [the file](file:///etc/passwd)." }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + }); + } finally { + rmrf(root); + } +}); + +test("rejects vscode: and javascript: schemes in a step description", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { description: "Click [here](vscode://file/x)." }, + { description: "Execute [this](javascript:alert(1))." }, + ], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + }); + } finally { + rmrf(root); + } +}); + +test("rejects a command scheme in a changes tour description", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "Run [this](command:workbench.action.quit)." }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + }); + } finally { + rmrf(root); + } +}); + +test("rejects an active scheme in the tour-level description", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + description: "See [the launch command](command:workbench.action.quit).", + steps: [{ description: "Fine step." }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + const issues = (response.structured.issues ?? []) as Array<{ path: string }>; + assert.ok(issues.some((issue) => issue.path === "description")); + }); + } finally { + rmrf(root); + } +}); + +test("exposes exactly two tools", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name).sort(); + assert.deepEqual(names, ["create_changes_tour", "create_project_tour"]); + const project = tools.tools.find((tool) => tool.name === "create_project_tour"); + assert.ok(project); + assert.ok(project!.description!.includes("Project Tour")); + const changes = tools.tools.find((tool) => tool.name === "create_changes_tour"); + assert.ok(changes); + assert.ok(changes!.description!.includes("Changes Tour")); + }); + } finally { + rmrf(root); + } +}); + +test("returns a human-readable message and a structured result", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "Hello." }], + }); + assert.equal(response.isError, false); + assert.ok(response.text.includes(".tours/project.tour")); + assert.equal(response.structured.status, "created"); + assert.equal(response.structured.path, ".tours/project.tour"); + assert.equal(response.structured.stepCount, 1); + assert.ok(Array.isArray(response.structured.warnings)); + }); + } finally { + rmrf(root); + } +}); diff --git a/packages/mcp-server/test/unit/validation.test.ts b/packages/mcp-server/test/unit/validation.test.ts new file mode 100644 index 00000000..ec4f2b7b --- /dev/null +++ b/packages/mcp-server/test/unit/validation.test.ts @@ -0,0 +1,177 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createContext } from "../../src/context"; +import { + MAX_RECOMMENDED_STEPS, + validateChangesParams, + validateProjectParams, + validateSteps, +} from "../../src/validation"; +import { rmrf, tempDir, writeFile } from "../helpers/test-utils"; + +test("validateProjectParams rejects non-object arguments", () => { + const { issues } = validateProjectParams("not-an-object"); + assert.ok(issues.some((issue) => issue.path === "$")); +}); + +test("validateProjectParams rejects unknown root fields", () => { + const { issues } = validateProjectParams({ when: "true", steps: [] }); + assert.ok(issues.some((issue) => issue.path === "when")); +}); + +test("validateProjectParams requires a string title and description", () => { + const { issues } = validateProjectParams({ + title: 5, + description: false, + steps: [], + }); + const paths = issues.map((issue) => issue.path); + assert.ok(paths.includes("title")); + assert.ok(paths.includes("description")); +}); + +test("validateChangesParams requires base and a full head SHA", () => { + const { issues } = validateChangesParams({ steps: [] }); + const paths = issues.map((issue) => issue.path); + assert.ok(paths.includes("base")); + assert.ok(paths.includes("head")); +}); + +test("validateChangesParams rejects a short head SHA", () => { + const { issues } = validateChangesParams({ + base: "main", + head: "deadbeef", + steps: [], + }); + assert.ok(issues.some((issue) => issue.path === "head")); +}); + +test("validateChangesParams rejects a non-boolean includeUncommitted", () => { + const { issues } = validateChangesParams({ + base: "main", + head: "a".repeat(40), + includeUncommitted: "yes", + steps: [], + }); + assert.ok(issues.some((issue) => issue.path === "includeUncommitted")); +}); + +function workspaceWithFiles(files: Record): string { + const dir = tempDir(); + for (const [name, content] of Object.entries(files)) { + writeFile(dir, name, content); + } + return dir; +} + +function validate(rawSteps: unknown[], root: string) { + return validateSteps(rawSteps, createContext(root)); +} + +test("a step rejects a file and a directory together", () => { + const root = workspaceWithFiles({ "a.ts": "x" }); + fs.mkdirSync(path.join(root, "sub")); + const { issues } = validate( + [{ description: "d", file: "a.ts", directory: "sub" }], + root + ); + assert.ok(issues.some((issue) => issue.path === "steps[0]")); + rmrf(root); +}); + +test("line and pattern are mutually exclusive", () => { + const root = workspaceWithFiles({ "a.ts": "one\ntwo\n" }); + const { issues } = validate( + [{ description: "d", file: "a.ts", line: 1, pattern: "two" }], + root + ); + assert.ok(issues.some((issue) => issue.message.includes("mutually exclusive"))); + rmrf(root); +}); + +test("line, pattern and selection require a file", () => { + const root = workspaceWithFiles({ "a.ts": "one\ntwo\n" }); + const { issues } = validate( + [ + { description: "d", line: 1 }, + { description: "d", pattern: "one" }, + { + description: "d", + selection: { start: { line: 1, character: 1 }, end: { line: 1, character: 2 } }, + }, + ], + root + ); + assert.equal(issues.length, 3); + rmrf(root); +}); + +test("line must be a positive integer", () => { + const root = workspaceWithFiles({ "a.ts": "one\n" }); + const { issues } = validate( + [ + { description: "d", file: "a.ts", line: 0 }, + { description: "d", file: "a.ts", line: 1.5 }, + ], + root + ); + assert.equal(issues.filter((issue) => issue.path.endsWith(".line")).length, 2); + rmrf(root); +}); + +test("a pattern must match exactly one occurrence", () => { + const root = workspaceWithFiles({ "a.ts": "one\ntwo\none\n" }); + const { issues } = validate( + [ + { description: "d", file: "a.ts", pattern: "one" }, + { description: "d", file: "a.ts", pattern: "three" }, + ], + root + ); + assert.equal(issues.length, 2); + rmrf(root); +}); + +test("an invalid regular expression is rejected", () => { + const root = workspaceWithFiles({ "a.ts": "one\n" }); + const { issues } = validate( + [{ description: "d", file: "a.ts", pattern: "(unclosed" }], + root + ); + assert.equal(issues.length, 1); + rmrf(root); +}); + +test("steps may anchor files and directories without line targeting", () => { + const root = workspaceWithFiles({ "a.ts": "one\ntwo\n" }); + fs.mkdirSync(path.join(root, "sub")); + const { steps, issues } = validate( + [ + { description: "d", file: "a.ts" }, + { description: "d", directory: "sub" }, + ], + root + ); + assert.equal(issues.length, 0); + assert.ok(steps); + assert.equal(steps!.length, 2); + rmrf(root); +}); + +test("content-only steps without any anchor are allowed", () => { + const root = workspaceWithFiles({ "a.ts": "one\n" }); + const { steps, issues } = validate( + [{ description: "intro" }, { description: "deleted file context" }], + root + ); + assert.equal(issues.length, 0); + assert.ok(steps); + assert.equal(steps!.length, 2); + rmrf(root); +}); + +test("the recommended step maximum is fifteen", () => { + assert.equal(MAX_RECOMMENDED_STEPS, 15); +}); diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 00000000..d69a0d76 --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "node16", + "moduleResolution": "node16", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "declaration": false, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/tsconfig.json b/tsconfig.json index e451c7ae..1d27ed67 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,6 @@ "strict": true, "noUnusedLocals": true }, + "include": ["src"], "exclude": ["node_modules"] } From f8b10c5cc6da7973b37b69c4ca7b84ecd83b22e6 Mon Sep 17 00:00:00 2001 From: dhuyet Date: Mon, 31 Aug 2026 16:28:32 +0200 Subject: [PATCH 02/14] refactor: rename parameters in Changes Tour to baseRef, headRef, and includeUncommittedChanges - Updated README.md to reflect changes in parameter names for the Changes Tour. - Modified package.json to include additional files in the distribution. - Enhanced cli.ts to enforce single --workspace-root argument. - Updated server.ts to use new parameter names and improve error handling. - Refactored types.ts to rename parameters in ChangesParams interface. - Adjusted validation.ts to validate new parameter names in Changes Tour. - Updated test-utils.ts to support environment variable overrides in server tests. - Refactored changes-tour.test.ts to use new parameter names and improve test coverage. - Added cli.test.ts to ensure correct handling of command-line arguments. - Created packaged-binary.test.ts to test the installed binary functionality. - Updated validation.test.ts to validate new parameter names and ensure correct behavior. --- packages/mcp-server/README.md | 14 +- packages/mcp-server/package.json | 2 + packages/mcp-server/src/cli.ts | 12 ++ packages/mcp-server/src/server.ts | 69 +++--- packages/mcp-server/src/types.ts | 6 +- packages/mcp-server/src/validation.ts | 30 +-- .../mcp-server/test/helpers/test-utils.ts | 18 +- .../test/integration/changes-tour.test.ts | 197 +++++++++++++----- .../mcp-server/test/integration/cli.test.ts | 25 +++ .../test/integration/packaged-binary.test.ts | 140 +++++++++++++ .../mcp-server/test/unit/validation.test.ts | 37 +++- 11 files changed, 430 insertions(+), 120 deletions(-) create mode 100644 packages/mcp-server/test/integration/cli.test.ts create mode 100644 packages/mcp-server/test/integration/packaged-binary.test.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 7b52095a..63b980bf 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -79,9 +79,9 @@ important components, and its main execution flows. | Argument | Type | Required | Description | | -------------------- | -------- | -------- | ------------------------------------------------------------- | -| `base` | string | yes | Git ref the branch diverged from. | -| `head` | string | yes | Full 40-character SHA of the analyzed commit; must equal the current `HEAD`. | -| `includeUncommitted` | boolean | no | Include uncommitted changes explicitly (default `false`). | +| `baseRef` | string | yes | Git ref the branch diverged from. | +| `headRef` | string | yes | Full 40-character SHA of the analyzed commit; must equal the current `HEAD`. | +| `includeUncommittedChanges` | boolean | no | Include uncommitted changes explicitly (default `false`). | | `title` | string | no | Defaults to `Changes on `. | | `description` | string | no | Optional description; provenance is always appended. | | `steps` | object[] | yes | Non-empty list of steps (see below). | @@ -122,9 +122,9 @@ of these codes: | `TOUR_STEPS_REQUIRED` | The steps list is missing or empty. | | `INVALID_PROPOSAL` | The proposal has validation issues (all listed in `issues`). | | `GIT_REPOSITORY_REQUIRED` | `create_changes_tour` was called outside a Git repository. | -| `STALE_HEAD` | `head` does not match the current `HEAD`. | -| `INVALID_BASE_REF` | The merge-base between `base` and `head` cannot be computed. | -| `NO_CHANGES` | No committed changes between the merge-base and the head; the previous tour file is preserved. | +| `STALE_HEAD` | `headRef` does not match the current `HEAD`. | +| `INVALID_BASE_REF` | The merge-base between `baseRef` and `headRef` cannot be computed. | +| `NO_CHANGES` | No committed changes between the merge-base and `headRef`; the previous tour file is preserved. | | `SCHEMA_VALIDATION_FAILED` | Internal: the generated tour did not validate against the CodeTour schema. | | `OUTPUT_PATH_ESCAPES_WORKSPACE` | The output directory resolves outside the workspace root. | @@ -144,7 +144,7 @@ Non-blocking warnings: - A reproducible Changes Tour records the exact analyzed head SHA as its `ref`, and generation fails with `STALE_HEAD` if `HEAD` changed since the analysis. Uncommitted changes are excluded by default (with a warning). -- With `includeUncommitted: true`, the Changes Tour has no `ref` and warns +- With `includeUncommittedChanges: true`, the Changes Tour has no `ref` and warns that it describes a non-reproducible local state. The reserved tour files (`.tours/project.tour` and `.tours/changes.tour`) are diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 96ad7332..f990ad2f 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -17,6 +17,8 @@ "main": "dist/src/server.js", "files": [ "dist/src", + "dist/package.json", + "dist/schema.json", "schema.json", "README.md", "LICENSE.txt" diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index a6d60ab5..acb1d787 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -33,8 +33,20 @@ function parseArgs(argv: string[]): ParsedArgs { } else if (argument === "--version" || argument === "-v") { result.version = true; } else if (argument === "--workspace-root") { + if (result.workspaceRoot !== undefined) { + console.error( + `Error: --workspace-root must be provided exactly once.\n\n${usage()}` + ); + process.exit(1); + } result.workspaceRoot = argv[++index]; } else if (argument.startsWith("--workspace-root=")) { + if (result.workspaceRoot !== undefined) { + console.error( + `Error: --workspace-root must be provided exactly once.\n\n${usage()}` + ); + process.exit(1); + } result.workspaceRoot = argument.slice("--workspace-root=".length); } else { console.error(`Unknown argument: ${argument}\n\n${usage()}`); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 964182a4..5abc1389 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -31,6 +31,7 @@ import { } from "./validation"; import packageJson from "../package.json"; + const CODETOUR_SCHEMA_URI = "https://aka.ms/codetour-schema"; const SERVER_NAME = "codetour-mcp"; const SERVER_VERSION = packageJson.version; @@ -60,13 +61,13 @@ const CHANGES_TOUR_DESCRIPTION = "of the same kind). You provide the fully written content; the server only validates and persists it " + "deterministically. A good Changes Tour ideally covers: the intent of the changes, the major " + "modifications, their impact, and the relevant tests. " + - "Arguments: base (required Git ref), head (required full 40-character SHA of the analyzed commit, " + - "which must equal the current HEAD), includeUncommitted (optional boolean, default false), an optional " + + "Arguments: baseRef (required Git ref), headRef (required full 40-character SHA of the analyzed commit, " + + "which must equal the current HEAD), includeUncommittedChanges (optional boolean, default false), an optional " + "title (defaults to \"Changes on \"), an optional description, and a required non-empty steps " + "array. Steps follow the same rules as the Project Tour (prefer a unique stable pattern over a line " + "number); steps may anchor unchanged files when they " + "provide essential context, and deleted files must be explained with steps that have no locator. " + - "Uncommitted changes are excluded by default and reported as a warning; pass includeUncommitted to " + + "Uncommitted changes are excluded by default and reported as a warning; pass includeUncommittedChanges to " + "include them explicitly. The description is automatically enriched with the base, merge-base and " + "head. On failure, the previous tour file is preserved."; @@ -101,9 +102,9 @@ const changesToolInputSchema = z title: z.unknown().optional(), description: z.unknown().optional(), steps: z.unknown().optional(), - base: z.unknown().optional(), - head: z.unknown().optional(), - includeUncommitted: z.unknown().optional(), + baseRef: z.unknown().optional(), + headRef: z.unknown().optional(), + includeUncommittedChanges: z.unknown().optional(), }) .passthrough(); @@ -210,9 +211,9 @@ async function handleCreateChangesTour( ); } - const base = params.base as string; - const head = params.head as string; - const includeUncommitted = params.includeUncommitted === true; + const baseRef = params.baseRef as string; + const headRef = params.headRef as string; + const includeUncommittedChanges = params.includeUncommittedChanges === true; if (!(await isGitRepository(ctx))) { return errorResponse( @@ -228,36 +229,36 @@ async function handleCreateChangesTour( "The repository has no commits, so there is no HEAD to analyze." ); } - if (head !== currentHead) { + if (headRef !== currentHead) { return errorResponse( "STALE_HEAD", - `The provided head ${head} does not match the current HEAD (${currentHead}). The analysis is stale; re-run it against the current HEAD.` + `The provided headRef ${headRef} does not match the current HEAD (${currentHead}). The analysis is stale; re-run it against the current HEAD.` ); } let mergeBaseSha: string; try { - mergeBaseSha = await mergeBase(ctx, base, head); + mergeBaseSha = await mergeBase(ctx, baseRef, headRef); } catch (error) { return errorResponse( "INVALID_BASE_REF", - `Unable to compute the merge-base between ${base} and ${head}: ${(error as Error).message}` + `Unable to compute the merge-base between ${baseRef} and ${headRef}: ${(error as Error).message}` ); } const uncommitted = await uncommittedChanges(ctx); if ( - (await committedDiffIsEmpty(ctx, mergeBaseSha, head)) && - (!includeUncommitted || uncommitted.length === 0) + (await committedDiffIsEmpty(ctx, mergeBaseSha, headRef)) && + (!includeUncommittedChanges || uncommitted.length === 0) ) { return errorResponse( "NO_CHANGES", - `No committed changes between the merge-base of ${base} (${mergeBaseSha}) and ${head}. The previous tour file was preserved.` + `No committed changes between the merge-base of ${baseRef} (${mergeBaseSha}) and ${headRef}. The previous tour file was preserved.` ); } const warnings: Warning[] = []; - if (includeUncommitted) { + if (includeUncommittedChanges) { warnings.push({ code: "UNCOMMITTED_CHANGES_INCLUDED", message: @@ -274,8 +275,8 @@ async function handleCreateChangesTour( const warningsFromStepLimit = stepLimitWarnings(finalSteps); warnings.push(...warningsFromStepLimit); - const changedInWorkspace = await changedFilesInWorkspace(ctx, mergeBaseSha, head); - if (includeUncommitted) { + const changedInWorkspace = await changedFilesInWorkspace(ctx, mergeBaseSha, headRef); + if (includeUncommittedChanges) { for (const entry of uncommitted) { if (!changedInWorkspace.includes(entry.path)) { changedInWorkspace.push(entry.path); @@ -297,10 +298,10 @@ async function handleCreateChangesTour( }); } - const title = params.title ?? (await defaultChangesTitle(ctx, head)); - const provenance = includeUncommitted - ? `Generated from the merge-base of \`${base}\` (\`${mergeBaseSha}\`) to \`${head}\`, including uncommitted changes (non-reproducible local state).` - : `Generated from the merge-base of \`${base}\` (\`${mergeBaseSha}\`) to \`${head}\`.`; + const title = params.title ?? (await defaultChangesTitle(ctx, headRef)); + const provenance = includeUncommittedChanges + ? `Generated from the merge-base of \`${baseRef}\` (\`${mergeBaseSha}\`) to \`${headRef}\`, including uncommitted changes (non-reproducible local state).` + : `Generated from the merge-base of \`${baseRef}\` (\`${mergeBaseSha}\`) to \`${headRef}\`.`; const description = params.description !== undefined ? `${params.description}\n\n${provenance}` @@ -310,10 +311,20 @@ async function handleCreateChangesTour( $schema: CODETOUR_SCHEMA_URI, title, description, - ...(!includeUncommitted ? { ref: head } : {}), + ...(!includeUncommittedChanges ? { ref: headRef } : {}), steps: finalSteps, }; + const headImmediatelyBeforePersistence = await currentHeadSha(ctx); + if (headImmediatelyBeforePersistence !== headRef) { + return errorResponse( + "STALE_HEAD", + headImmediatelyBeforePersistence === null + ? "The repository no longer has a HEAD to persist this analysis against. The previous tour file was preserved." + : `The provided head ${headRef} no longer matches the current HEAD (${headImmediatelyBeforePersistence}). The analysis became stale before persistence; re-run it against the current HEAD.` + ); + } + const writeFailure = await persistTour(ctx, CHANGES_TOUR_PATH, tour); if (writeFailure) { return writeFailure; @@ -323,7 +334,7 @@ async function handleCreateChangesTour( CHANGES_TOUR_PATH, finalSteps.length, warnings, - `Created Changes Tour at ${CHANGES_TOUR_PATH} with ${finalSteps.length} step(s) for head ${head} (base ${base}).` + `Created Changes Tour at ${CHANGES_TOUR_PATH} with ${finalSteps.length} step(s) for head ${headRef} (base ${baseRef}).` ); } @@ -373,9 +384,9 @@ function extractSteps(args: unknown): unknown { async function changedFilesInWorkspace( ctx: WorkspaceContext, mergeBaseSha: string, - head: string + headRef: string ): Promise { - const files = await changedFiles(ctx, mergeBaseSha, head); + const files = await changedFiles(ctx, mergeBaseSha, headRef); const prefix = normalizeSlashes(await workspacePrefix(ctx)); return files .map(normalizeSlashes) @@ -385,13 +396,13 @@ async function changedFilesInWorkspace( async function defaultChangesTitle( ctx: WorkspaceContext, - head: string + headRef: string ): Promise { const branch = await currentBranchName(ctx); if (branch && branch !== "HEAD") { return `Changes on ${branch}`; } - return `Changes at ${head.slice(0, 7)}`; + return `Changes at ${headRef.slice(0, 7)}`; } function serializeTour(tour: TourFile): string { diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts index 733dcafd..c66367a6 100644 --- a/packages/mcp-server/src/types.ts +++ b/packages/mcp-server/src/types.ts @@ -25,9 +25,9 @@ export interface ProjectParams { } export interface ChangesParams extends ProjectParams { - base?: string; - head?: string; - includeUncommitted?: boolean; + baseRef?: string; + headRef?: string; + includeUncommittedChanges?: boolean; } export interface TourFile { diff --git a/packages/mcp-server/src/validation.ts b/packages/mcp-server/src/validation.ts index 7af0056b..9501318a 100644 --- a/packages/mcp-server/src/validation.ts +++ b/packages/mcp-server/src/validation.ts @@ -76,9 +76,9 @@ const CHANGES_PARAM_FIELDS = [ "title", "description", "steps", - "base", - "head", - "includeUncommitted", + "baseRef", + "headRef", + "includeUncommittedChanges", ] as const; export function validateProjectParams( @@ -104,23 +104,23 @@ export function validateChangesParams( } reportUnknownFields(raw, CHANGES_PARAM_FIELDS, "$", issues); const params = validateCommonParams(raw, issues) as ChangesParams; - if (typeof raw.base !== "string" || raw.base.trim() === "") { - issues.push({ path: "base", message: "is required and must be a non-empty string" }); + if (typeof raw.baseRef !== "string" || raw.baseRef.trim() === "") { + issues.push({ path: "baseRef", message: "is required and must be a non-empty string" }); } else { - params.base = raw.base; + params.baseRef = raw.baseRef; } - if (typeof raw.head !== "string") { - issues.push({ path: "head", message: "is required and must be the full 40-character commit SHA" }); - } else if (!FULL_SHA_PATTERN.test(raw.head)) { - issues.push({ path: "head", message: "must be the full 40-character commit SHA" }); + if (typeof raw.headRef !== "string") { + issues.push({ path: "headRef", message: "is required and must be the full 40-character commit SHA" }); + } else if (!FULL_SHA_PATTERN.test(raw.headRef)) { + issues.push({ path: "headRef", message: "must be the full 40-character commit SHA" }); } else { - params.head = raw.head; + params.headRef = raw.headRef; } - if (raw.includeUncommitted !== undefined) { - if (typeof raw.includeUncommitted !== "boolean") { - issues.push({ path: "includeUncommitted", message: "must be a boolean" }); + if (raw.includeUncommittedChanges !== undefined) { + if (typeof raw.includeUncommittedChanges !== "boolean") { + issues.push({ path: "includeUncommittedChanges", message: "must be a boolean" }); } else { - params.includeUncommitted = raw.includeUncommitted; + params.includeUncommittedChanges = raw.includeUncommittedChanges; } } return { params, issues }; diff --git a/packages/mcp-server/test/helpers/test-utils.ts b/packages/mcp-server/test/helpers/test-utils.ts index 141843bc..a9a1544c 100644 --- a/packages/mcp-server/test/helpers/test-utils.ts +++ b/packages/mcp-server/test/helpers/test-utils.ts @@ -16,7 +16,10 @@ export interface ToolResponse { structured: Record; } -export async function startServer(workspaceRoot: string): Promise { +export async function startServer( + workspaceRoot: string, + envOverrides: Record = {} +): Promise { const client = new Client( { name: "codetour-mcp-test-client", version: "0.0.0" }, { capabilities: {} } @@ -24,6 +27,14 @@ export async function startServer(workspaceRoot: string): Promise { const transport = new StdioClientTransport({ command: process.execPath, args: [CLI_PATH, "--workspace-root", workspaceRoot], + env: { + ...Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ), + ...envOverrides, + }, }); await client.connect(transport); return client; @@ -31,9 +42,10 @@ export async function startServer(workspaceRoot: string): Promise { export async function withServer( root: string, - run: (client: Client) => Promise + run: (client: Client) => Promise, + envOverrides: Record = {} ): Promise { - const client = await startServer(root); + const client = await startServer(root, envOverrides); try { return await run(client); } finally { diff --git a/packages/mcp-server/test/integration/changes-tour.test.ts b/packages/mcp-server/test/integration/changes-tour.test.ts index d01be623..b61d1a55 100644 --- a/packages/mcp-server/test/integration/changes-tour.test.ts +++ b/packages/mcp-server/test/integration/changes-tour.test.ts @@ -1,5 +1,7 @@ import { test } from "node:test"; import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { callTool, commitFile, @@ -32,8 +34,8 @@ test("creates a changes tour with the exact head SHA as ref", async () => { try { await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, description: "Explains the feature branch.", steps: [ { description: "Intent of the change." }, @@ -64,8 +66,8 @@ test("uses the provided title when given", async () => { try { await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, title: "Custom Title", steps: [{ description: "d", file: "feature.txt" }], }); @@ -83,8 +85,8 @@ test("fails with STALE_HEAD when HEAD moved since the analysis", async () => { await commitFile(root, "extra.txt", "extra\n", "extra commit"); await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [{ description: "d" }], }); assert.equal(response.isError, true); @@ -95,14 +97,69 @@ test("fails with STALE_HEAD when HEAD moved since the analysis", async () => { } }); -test("fails with STALE_HEAD when the head argument is not the current HEAD", async () => { +test("preserves the previous tour when HEAD moves immediately before persistence", async () => { + const { root, baseSha, head } = await setupRepo(); + const shimDir = tempDir("codetour-git-shim-"); + const reachedFinalCheck = path.join(shimDir, "reached-final-check"); + const continueRequest = path.join(shimDir, "continue-request"); + const realGit = (process.env.PATH ?? "") + .split(path.delimiter) + .map((directory) => path.join(directory, "git")) + .find((candidate) => fs.existsSync(candidate)); + assert.ok(realGit, "git must be available on PATH"); + const gitShim = path.join(shimDir, "git"); + writeFile( + shimDir, + "git", + `#!/bin/sh +if [ "$*" = "rev-parse --abbrev-ref HEAD" ]; then + : > "${reachedFinalCheck}" + while [ ! -e "${continueRequest}" ]; do sleep 0.01; done +fi +exec "${realGit}" "$@" +` + ); + fs.chmodSync(gitShim, 0o755); + + try { + writeFile(root, ".tours/changes.tour", JSON.stringify({ title: "Previous tour" })); + const before = readTourFile(root, ".tours/changes.tour"); + + await withServer( + root, + async (client) => { + const request = callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "Should not land.", file: "feature.txt" }], + }); + while (!fs.existsSync(reachedFinalCheck)) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + await commitFile(root, "late.txt", "late change\n", "move HEAD during request"); + writeFile(shimDir, "continue-request", "continue\n"); + + const response = await request; + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "STALE_HEAD"); + }, + { PATH: `${shimDir}:${process.env.PATH ?? ""}` } + ); + assert.deepEqual(readTourFile(root, ".tours/changes.tour"), before); + } finally { + rmrf(root); + rmrf(shimDir); + } +}); + +test("fails with STALE_HEAD when headRef is not the current HEAD", async () => { const { root, baseSha } = await setupRepo(); try { const other = baseSha; await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head: other, + baseRef: baseSha, + headRef: other, steps: [{ description: "d" }], }); assert.equal(response.isError, true); @@ -118,8 +175,8 @@ test("fails with GIT_REPOSITORY_REQUIRED outside a git repository", async () => try { await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: "main", - head: "a".repeat(40), + baseRef: "main", + headRef: "a".repeat(40), steps: [{ description: "d" }], }); assert.equal(response.isError, true); @@ -135,8 +192,8 @@ test("fails with NO_CHANGES when the range has no committed changes", async () = try { await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: head, - head, + baseRef: head, + headRef: head, steps: [{ description: "d" }], }); assert.equal(response.isError, true); @@ -152,8 +209,8 @@ test("fails with INVALID_BASE_REF when the merge-base cannot be computed", async try { await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: "no-such-branch", - head, + baseRef: "no-such-branch", + headRef: head, steps: [{ description: "d" }], }); assert.equal(response.isError, true); @@ -169,14 +226,14 @@ test("keeps the previous tour when NO_CHANGES occurs", async () => { try { await withServer(root, async (client) => { const first = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [{ description: "Original.", file: "feature.txt" }], }); assert.equal(first.isError, false); const second = await callTool(client, "create_changes_tour", { - base: head, - head, + baseRef: head, + headRef: head, steps: [{ description: "Should not replace." }], }); assert.equal(second.isError, true); @@ -198,8 +255,8 @@ test("warns when uncommitted changes are excluded", async () => { writeFile(root, "untracked.txt", "new local file\n"); await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [{ description: "d", file: "feature.txt" }], }); assert.equal(response.isError, false); @@ -217,8 +274,8 @@ test("ignores the reserved tour files when detecting a dirty workspace", async ( writeFile(root, ".tours/project.tour", "{ generated earlier }"); await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [{ description: "d", file: "feature.txt" }], }); assert.equal(response.isError, false); @@ -235,9 +292,9 @@ test("includes uncommitted changes explicitly and drops the ref", async () => { writeFile(root, "work-in-progress.txt", "local work\n"); await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, - includeUncommitted: true, + baseRef: baseSha, + headRef: head, + includeUncommittedChanges: true, steps: [ { description: "Local work.", file: "work-in-progress.txt" }, ], @@ -259,8 +316,8 @@ test("warns with NO_CHANGED_FILE_ANCHOR when no step anchors a changed file", as try { await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [{ description: "Only context, no anchors." }], }); assert.equal(response.isError, false); @@ -276,8 +333,8 @@ test("does not warn when a step anchors a changed file", async () => { try { await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [{ description: "The new file.", file: "feature.txt" }], }); assert.equal(response.isError, false); @@ -300,8 +357,8 @@ test("allows deletion-only branches with content-only steps", async () => { try { await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [{ description: "We removed victim.txt entirely." }], }); assert.equal(response.isError, false); @@ -319,8 +376,8 @@ test("allows anchoring unchanged files for essential context", async () => { try { await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [ { description: "Context from the base file.", file: "base.txt" }, { description: "The new file.", file: "feature.txt" }, @@ -339,8 +396,8 @@ test("aggregates step validation errors for a changes tour", async () => { try { await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [ { description: "d", file: "missing.txt" }, { description: "d", file: "feature.txt", line: 99 }, @@ -370,8 +427,8 @@ test("works when the workspace root is a subdirectory of the repository", async try { await withServer(subRoot, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [{ description: "The new file.", file: "new.ts" }], }); assert.equal(response.isError, false); @@ -391,8 +448,8 @@ test("warns when the tour exceeds fifteen steps for a changes tour", async () => })); await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps, }); assert.equal(response.isError, false); @@ -411,8 +468,8 @@ test("warns when only staged changes are excluded", async () => { await git(["add", "staged.txt"], root); await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [{ description: "d", file: "feature.txt" }], }); assert.equal(response.isError, false); @@ -423,15 +480,51 @@ test("warns when only staged changes are excluded", async () => { } }); +test("warns when only unstaged changes are excluded", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, "feature.txt", "committed feature\nlocal edit\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "The committed feature.", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_EXCLUDED")); + }); + } finally { + rmrf(root); + } +}); + +test("warns when only untracked changes are excluded", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, "untracked.txt", "not committed\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "The committed feature.", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_EXCLUDED")); + }); + } finally { + rmrf(root); + } +}); + test("explains local work when only uncommitted changes exist", async () => { const { root, head } = await setupRepo(); try { writeFile(root, "work-in-progress.txt", "local work\n"); await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: head, - head, - includeUncommitted: true, + baseRef: head, + headRef: head, + includeUncommittedChanges: true, steps: [{ description: "Local work.", file: "work-in-progress.txt" }], }); assert.equal(response.isError, false); @@ -452,9 +545,9 @@ test("counts uncommitted files as changed for NO_CHANGED_FILE_ANCHOR", async () writeFile(root, "local-only.txt", "local work\n"); await withServer(root, async (client) => { const response = await callTool(client, "create_changes_tour", { - base: baseSha, - head, - includeUncommitted: true, + baseRef: baseSha, + headRef: head, + includeUncommittedChanges: true, steps: [{ description: "Local work.", file: "local-only.txt" }], }); assert.equal(response.isError, false); @@ -470,8 +563,8 @@ test("preserves the previous tour when the write fails", async () => { try { await withServer(root, async (client) => { const first = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [{ description: "Original.", file: "feature.txt" }], }); assert.equal(first.isError, false); @@ -483,8 +576,8 @@ test("preserves the previous tour when the write fails", async () => { try { await withServer(root, async (client) => { const second = await callTool(client, "create_changes_tour", { - base: baseSha, - head, + baseRef: baseSha, + headRef: head, steps: [{ description: "Should not land.", file: "feature.txt" }], }); assert.equal(second.isError, true); diff --git a/packages/mcp-server/test/integration/cli.test.ts b/packages/mcp-server/test/integration/cli.test.ts new file mode 100644 index 00000000..e6a3892a --- /dev/null +++ b/packages/mcp-server/test/integration/cli.test.ts @@ -0,0 +1,25 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { spawnSync } from "node:child_process"; +import { CLI_PATH, rmrf, tempDir } from "../helpers/test-utils"; + +test("rejects more than one --workspace-root argument", () => { + const root = tempDir(); + try { + const result = spawnSync( + process.execPath, + [ + CLI_PATH, + "--workspace-root", + root, + `--workspace-root=${root}`, + ], + { encoding: "utf8", timeout: 5_000 } + ); + + assert.equal(result.status, 1); + assert.match(result.stderr, /--workspace-root must be provided exactly once/); + } finally { + rmrf(root); + } +}); diff --git a/packages/mcp-server/test/integration/packaged-binary.test.ts b/packages/mcp-server/test/integration/packaged-binary.test.ts new file mode 100644 index 00000000..651445a2 --- /dev/null +++ b/packages/mcp-server/test/integration/packaged-binary.test.ts @@ -0,0 +1,140 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import * as assert from "node:assert"; +import { execFile } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import { test } from "node:test"; +import { + callTool, + commitFile, + headSha, + initGitRepo, + readTourFile, + rmrf, + tempDir, +} from "../helpers/test-utils"; + +const execFileAsync = promisify(execFile); +const packageRoot = path.join(__dirname, "..", "..", ".."); + +async function npm(args: string[], cwd: string, cache: string): Promise { + const npmCli = process.env.npm_execpath; + const command = npmCli ? process.execPath : "npm"; + const commandArgs = npmCli ? [npmCli, ...args] : args; + const result = await execFileAsync(command, commandArgs, { + cwd, + encoding: "utf8", + env: { + ...process.env, + npm_config_cache: cache, + npm_config_update_notifier: "false", + }, + }); + return result.stdout; +} + +test("the installed codetour-mcp binary serves both public MCP tools", async () => { + const sandbox = tempDir("codetour-mcp-package-test-"); + const archiveDir = path.join(sandbox, "archive"); + const installationRoot = path.join(sandbox, "consumer"); + const workspaceRoot = path.join(sandbox, "workspace"); + const npmCache = path.join(sandbox, "npm-cache"); + + try { + fs.mkdirSync(archiveDir, { recursive: true }); + await npm( + ["pack", "--json", "--pack-destination", archiveDir], + packageRoot, + npmCache + ); + const filename = fs + .readdirSync(archiveDir) + .find((entry) => entry.endsWith(".tgz")); + assert.ok(filename, "npm pack should create a package archive"); + const archivePath = path.join(archiveDir, filename); + + // Seed the consumer with the already-installed dependency tree. This keeps + // the package-install smoke test deterministic and fully offline. + fs.mkdirSync(installationRoot, { recursive: true }); + fs.cpSync( + path.join(packageRoot, "node_modules"), + path.join(installationRoot, "node_modules"), + { recursive: true } + ); + await npm( + [ + "install", + "--prefix", + installationRoot, + "--ignore-scripts", + "--offline", + "--package-lock=false", + "--no-audit", + "--no-fund", + archivePath, + path.join(packageRoot, "node_modules", "@modelcontextprotocol", "sdk"), + path.join(packageRoot, "node_modules", "ajv"), + path.join(packageRoot, "node_modules", "zod"), + ], + sandbox, + npmCache + ); + + fs.mkdirSync(workspaceRoot, { recursive: true }); + await initGitRepo(workspaceRoot); + await commitFile(workspaceRoot, "base.txt", "base\n", "add base"); + const baseRef = await headSha(workspaceRoot); + await commitFile(workspaceRoot, "feature.txt", "feature\n", "add feature"); + const headRef = await headSha(workspaceRoot); + assert.match(baseRef, /^[0-9a-f]{40}$/); + assert.match(headRef, /^[0-9a-f]{40}$/); + + const binaryPath = path.join( + installationRoot, + "node_modules", + ".bin", + "codetour-mcp" + ); + const client = new Client( + { name: "packaged-binary-test-client", version: "0.0.0" }, + { capabilities: {} } + ); + const transport = new StdioClientTransport({ + command: binaryPath, + args: ["--workspace-root", workspaceRoot], + }); + await client.connect(transport); + try { + const tools = await client.listTools(); + const changesTool = tools.tools.find((tool) => tool.name === "create_changes_tour"); + assert.ok(changesTool); + assert.ok( + Object.prototype.hasOwnProperty.call( + (changesTool.inputSchema.properties ?? {}) as object, + "headRef" + ), + JSON.stringify(changesTool.inputSchema) + ); + const changesTour = await callTool(client, "create_changes_tour", { + baseRef, + headRef, + steps: [{ description: "The feature.", file: "feature.txt" }], + }); + assert.equal(changesTour.isError, false, changesTour.text); + + const projectTour = await callTool(client, "create_project_tour", { + steps: [{ description: "The project base.", file: "base.txt" }], + }); + assert.equal(projectTour.isError, false); + } finally { + await client.close(); + } + + assert.equal(readTourFile(workspaceRoot, ".tours/project.tour").title, "Project Overview"); + assert.equal(readTourFile(workspaceRoot, ".tours/changes.tour").ref, headRef); + } finally { + rmrf(sandbox); + } +}); diff --git a/packages/mcp-server/test/unit/validation.test.ts b/packages/mcp-server/test/unit/validation.test.ts index ec4f2b7b..ed3c0015 100644 --- a/packages/mcp-server/test/unit/validation.test.ts +++ b/packages/mcp-server/test/unit/validation.test.ts @@ -32,30 +32,45 @@ test("validateProjectParams requires a string title and description", () => { assert.ok(paths.includes("description")); }); -test("validateChangesParams requires base and a full head SHA", () => { +test("validateChangesParams requires baseRef and a full headRef SHA", () => { const { issues } = validateChangesParams({ steps: [] }); const paths = issues.map((issue) => issue.path); - assert.ok(paths.includes("base")); - assert.ok(paths.includes("head")); + assert.ok(paths.includes("baseRef")); + assert.ok(paths.includes("headRef")); +}); + +test("validateChangesParams accepts the public Changes Tour contract", () => { + const headRef = "a".repeat(40); + const { params, issues } = validateChangesParams({ + baseRef: "main", + headRef, + includeUncommittedChanges: true, + steps: [{ description: "Explain the change." }], + }); + + assert.deepEqual(issues, []); + assert.equal(params?.baseRef, "main"); + assert.equal(params?.headRef, headRef); + assert.equal(params?.includeUncommittedChanges, true); }); test("validateChangesParams rejects a short head SHA", () => { const { issues } = validateChangesParams({ - base: "main", - head: "deadbeef", + baseRef: "main", + headRef: "deadbeef", steps: [], }); - assert.ok(issues.some((issue) => issue.path === "head")); + assert.ok(issues.some((issue) => issue.path === "headRef")); }); -test("validateChangesParams rejects a non-boolean includeUncommitted", () => { +test("validateChangesParams rejects a non-boolean includeUncommittedChanges", () => { const { issues } = validateChangesParams({ - base: "main", - head: "a".repeat(40), - includeUncommitted: "yes", + baseRef: "main", + headRef: "a".repeat(40), + includeUncommittedChanges: "yes", steps: [], }); - assert.ok(issues.some((issue) => issue.path === "includeUncommitted")); + assert.ok(issues.some((issue) => issue.path === "includeUncommittedChanges")); }); function workspaceWithFiles(files: Record): string { From 753fd15b52b0f51b1ca9057a3815c90e7db80ed6 Mon Sep 17 00:00:00 2001 From: dhuyet Date: Thu, 3 Sep 2026 11:42:29 +0200 Subject: [PATCH 03/14] feat: bundle and register the MCP server --- .vscodeignore | 5 ++++- package-lock.json | 13 +++++++------ package.json | 18 +++++++++++++----- src/extension.ts | 4 ++++ src/mcp.ts | 27 +++++++++++++++++++++++++++ src/player/index.ts | 5 ++++- src/recorder/commands.ts | 10 +++++++--- webpack.config.js | 13 ++++++++++++- 8 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 src/mcp.ts diff --git a/.vscodeignore b/.vscodeignore index 75620af6..98bdd56c 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,13 +1,16 @@ .tours/** .vscode/** node_modules/** +packages/mcp-server/** src/** +scripts/** .gitignore .space package.lock.json tsconfig.json webpack.config.js +dist/test/** **/*.vsix .git/** .github/** -**/*.svg \ No newline at end of file +**/*.svg diff --git a/package-lock.json b/package-lock.json index 54c0d1c4..841eea90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "devDependencies": { "@types/node": "^18.14.0", "@types/throttle-debounce": "^5.0.0", - "@types/vscode": "^1.60", + "@types/vscode": "1.101.0", "@vscode/vsce": "^2.17.0", "debug": "^4.3.4", "eslint": "^8.34.0", @@ -32,7 +32,7 @@ "webpack-merge": "^5.8.0" }, "engines": { - "vscode": "^1.60.0" + "vscode": "^1.101.0" } }, "node_modules/@babel/runtime": { @@ -293,10 +293,11 @@ "dev": true }, "node_modules/@types/vscode": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.60.0.tgz", - "integrity": "sha512-wZt3VTmzYrgZ0l/3QmEbCq4KAJ71K3/hmMQ/nfpv84oH8e81KKwPEoQ5v8dNCxfHFVJ1JabHKmCvqdYOoVm1Ow==", - "dev": true + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.101.0.tgz", + "integrity": "sha512-ZWf0IWa+NGegdW3iU42AcDTFHWW7fApLdkdnBqwYEtHVIBGbTu0ZNQKP/kX3Ds/uMJXIMQNAojHR4vexCEEz5Q==", + "dev": true, + "license": "MIT" }, "node_modules/@vscode/vsce": { "version": "2.17.0", diff --git a/package.json b/package.json index 619cb076..6995d770 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "license": "MIT", "icon": "images/icon.png", "engines": { - "vscode": "^1.60.0" + "vscode": "^1.101.0" }, "categories": [ "Other" @@ -37,6 +37,12 @@ "main": "./dist/extension-node.js", "browser": "./dist/extension-web.js", "contributes": { + "mcpServerDefinitionProviders": [ + { + "id": "codetour.tour-generator", + "label": "CodeTour Tour Generator" + } + ], "configuration": { "type": "object", "title": "CodeTour", @@ -664,7 +670,7 @@ "devDependencies": { "@types/node": "^18.14.0", "@types/throttle-debounce": "^5.0.0", - "@types/vscode": "^1.60", + "@types/vscode": "1.101.0", "@vscode/vsce": "^2.17.0", "debug": "^4.3.4", "eslint": "^8.34.0", @@ -675,13 +681,15 @@ "webpack-merge": "^5.8.0" }, "scripts": { - "build": "webpack --mode production", + "build:mcp": "npm --prefix packages/mcp-server run build", + "build": "npm run build:mcp && webpack --mode production", "vscode:prepublish": "npm run build", "watch": "webpack --mode development --watch", - "package": "vsce package" + "package": "vsce package", + "test:mcp": "npm --prefix packages/mcp-server test" }, "prettier": { "arrowParens": "avoid", "trailingComma": "none" } -} \ No newline at end of file +} diff --git a/src/extension.ts b/src/extension.ts index b72a4b8c..8fea91d8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,7 @@ import * as vscode from "vscode"; import { initializeApi } from "./api"; import { initializeGitApi } from "./git"; import { registerLiveShareModule } from "./liveShare"; +import { registerMcpProvider } from "./mcp"; import { registerPlayerModule } from "./player"; import { registerRecorderModule } from "./recorder"; import { store } from "./store"; @@ -75,6 +76,9 @@ class URIHandler implements vscode.UriHandler { } export async function activate(context: vscode.ExtensionContext) { + if (vscode.env.uiKind === vscode.UIKind.Desktop) { + registerMcpProvider(context); + } registerPlayerModule(context); registerRecorderModule(); registerLiveShareModule(); diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 00000000..66d59803 --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,27 @@ +import * as path from "path"; +import * as vscode from "vscode"; + +export const MCP_PROVIDER_ID = "codetour.tour-generator"; + +export function bundledMcpServerPath(context: vscode.ExtensionContext): string { + return path.join(context.extensionPath, "dist", "mcp-server.js"); +} + +export function registerMcpProvider(context: vscode.ExtensionContext): void { + const serverPath = bundledMcpServerPath(context); + const extensionVersion = String(context.extension.packageJSON.version); + context.subscriptions.push( + vscode.lm.registerMcpServerDefinitionProvider(MCP_PROVIDER_ID, { + provideMcpServerDefinitions: () => + (vscode.workspace.workspaceFolders ?? []).map(folder => + new vscode.McpStdioServerDefinition( + `CodeTour (${folder.name})`, + process.execPath, + [serverPath, "--workspace-root", folder.uri.fsPath], + { ELECTRON_RUN_AS_NODE: "1" }, + extensionVersion + ) + ) + }) + ); +} diff --git a/src/player/index.ts b/src/player/index.ts index 2e5935b0..291077df 100644 --- a/src/player/index.ts +++ b/src/player/index.ts @@ -136,7 +136,10 @@ export class CodeTourComment implements Comment { let controller: CommentController | null; export async function focusPlayer() { - const currentThread = store.activeTour!.thread!; + const currentThread = store.activeTour?.thread; + if (!currentThread?.range) { + return; + } showDocument(currentThread.uri, currentThread.range); } diff --git a/src/recorder/commands.ts b/src/recorder/commands.ts index f9d782c7..6933dcd6 100644 --- a/src/recorder/commands.ts +++ b/src/recorder/commands.ts @@ -373,6 +373,10 @@ export function registerRecorderCommands() { const tour = store.activeTour!.tour; const thread = store.activeTour!.thread; + const range = thread.range; + if (!range) { + throw new Error("A file Tour step requires a source range."); + } const workspaceRoot = getActiveWorkspacePath(); const file = getRelativePath(workspaceRoot, thread!.uri.path); @@ -391,7 +395,7 @@ export function registerRecorderCommands() { editor => editor.document && editor.document.uri.scheme === "file" ); const contents = fileEditors?.[0]?.document - .lineAt(thread.range.start.line) + .lineAt(range.start.line) .text.trim(); const pattern = @@ -407,10 +411,10 @@ export function registerRecorderCommands() { step.pattern = pattern; } else { // TODO: Try to get smarter about how to handle this. - step.line = thread.range.start.line + 1; + step.line = range.start.line + 1; } } else { - step.line = thread.range.start.line + 1; + step.line = range.start.line + 1; } store.activeTour!.step++; diff --git a/webpack.config.js b/webpack.config.js index f66c0105..ebb1fa2c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -63,4 +63,15 @@ const webConfig = { } }; -module.exports = [nodeConfig, webConfig]; \ No newline at end of file +const mcpConfig = { + mode: "production", + target: "node18", + entry: "./packages/mcp-server/dist/src/cli.js", + devtool: "source-map", + output: { + path: path.resolve(__dirname, "dist"), + filename: "mcp-server.js" + } +}; + +module.exports = [nodeConfig, webConfig, mcpConfig]; From 483f1bedc0f217434c617e094a1e1c8ed21ee198 Mon Sep 17 00:00:00 2001 From: dhuyet Date: Thu, 3 Sep 2026 11:42:29 +0200 Subject: [PATCH 04/14] docs: explain MCP discovery and distribution --- CHANGELOG.md | 3 +++ README.md | 8 ++++++++ ...-and-discover-the-mcp-server-with-the-extension.md | 11 +++++++++++ 3 files changed, 22 insertions(+) create mode 100644 docs/adr/0006-distribute-and-discover-the-mcp-server-with-the-extension.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c12c216f..c3e7ec54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Upcoming +- Added a bundled, workspace-confined MCP server for generating Project Tours + and Changes Tours from VS Code and GitHub Copilot. + - Automatically updating a tour file as the associated code changes - Automatically set the "pattern" record mode when you create a new tour, and select `None` for the git ref - Added support for opening a `*.tour` file in the VS Code notebook editor (Insiders only) diff --git a/README.md b/README.md index 46fafad7..f6d2891b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # CodeTour 🗺️ +## AI-generated tours + +The desktop extension includes a local MCP server for generating a Project +Tour or a Changes Tour. VS Code and GitHub Copilot discover one server per +workspace folder automatically. The server is bundled with the extension, +runs over stdio, remains confined to its workspace, and does not access the +network. + CodeTour is a Visual Studio Code extension, which allows you to record and play back guided walkthroughs of your codebases. It's like a table of contents, that can make it easier to onboard (or re-board!) to a new project/feature area, visualize bug reports, or understand the context of a code review/PR change. A "code tour" is simply a series of interactive steps, each of which are associated with a specific directory, or file/line, and include a description of the respective code. This allows developers to clone a repo, and then immediately start **learning it**, without needing to refer to a `CONTRIBUTING.md` file and/or rely on help from others. Tours can either be checked into a repo, to enable sharing with other contributors, or [exported](#exporting-tours) to a "tour file", which allows anyone to replay the same tour, without having to clone any code to do it! diff --git a/docs/adr/0006-distribute-and-discover-the-mcp-server-with-the-extension.md b/docs/adr/0006-distribute-and-discover-the-mcp-server-with-the-extension.md new file mode 100644 index 00000000..c0f36852 --- /dev/null +++ b/docs/adr/0006-distribute-and-discover-the-mcp-server-with-the-extension.md @@ -0,0 +1,11 @@ +# Distribute and discover the MCP server with the extension + +The desktop extension bundles the compiled stdio MCP server and registers one +server definition per workspace folder. This allows VS Code and GitHub Copilot +to discover the tools without a workspace-specific MCP configuration file. + +Each server receives one explicit workspace root and remains confined to it. +The web extension does not register or start the Node.js server. + +The MCP server definition API requires VS Code 1.101, so the extension's +minimum VS Code version moves to 1.101. From 572cc0b419e8559d37e65815d9727dc395cc6eeb Mon Sep 17 00:00:00 2001 From: dhuyet Date: Thu, 3 Sep 2026 11:43:17 +0200 Subject: [PATCH 05/14] fix: exclude MCP sources from the VSIX --- .vscodeignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscodeignore b/.vscodeignore index 98bdd56c..ab7ef305 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,9 +1,10 @@ .tours/** .vscode/** node_modules/** -packages/mcp-server/** +packages/** src/** scripts/** +docs/** .gitignore .space package.lock.json From a3962710479372f17b7added7f8b7bdcdb500b93 Mon Sep 17 00:00:00 2001 From: dhuyet Date: Thu, 3 Sep 2026 11:49:15 +0200 Subject: [PATCH 06/14] feat: add a secure Mermaid description renderer --- packages/description-renderer/LICENSE.txt | 21 + packages/description-renderer/README.md | 119 + .../description-renderer/package-lock.json | 1990 +++++++++++++++++ packages/description-renderer/package.json | 39 + .../description-renderer/src/description.ts | 146 ++ packages/description-renderer/src/dom.ts | 518 +++++ packages/description-renderer/src/index.ts | 31 + packages/description-renderer/src/measure.ts | 66 + packages/description-renderer/src/parse.ts | 81 + .../description-renderer/src/rasterize.ts | 21 + packages/description-renderer/src/render.ts | 218 ++ packages/description-renderer/src/rules.ts | 76 + packages/description-renderer/src/sanitize.ts | 55 + .../description-renderer/test/cache.test.ts | 41 + .../test/helpers/fixtures.ts | 213 ++ .../test/mermaid-save.test.ts | 56 + .../description-renderer/test/offline.test.ts | 141 ++ .../description-renderer/test/rules.test.ts | 160 ++ .../description-renderer/test/seam.test.ts | 410 ++++ .../test/security.test.ts | 211 ++ packages/description-renderer/tsconfig.json | 19 + 21 files changed, 4632 insertions(+) create mode 100644 packages/description-renderer/LICENSE.txt create mode 100644 packages/description-renderer/README.md create mode 100644 packages/description-renderer/package-lock.json create mode 100644 packages/description-renderer/package.json create mode 100644 packages/description-renderer/src/description.ts create mode 100644 packages/description-renderer/src/dom.ts create mode 100644 packages/description-renderer/src/index.ts create mode 100644 packages/description-renderer/src/measure.ts create mode 100644 packages/description-renderer/src/parse.ts create mode 100644 packages/description-renderer/src/rasterize.ts create mode 100644 packages/description-renderer/src/render.ts create mode 100644 packages/description-renderer/src/rules.ts create mode 100644 packages/description-renderer/src/sanitize.ts create mode 100644 packages/description-renderer/test/cache.test.ts create mode 100644 packages/description-renderer/test/helpers/fixtures.ts create mode 100644 packages/description-renderer/test/mermaid-save.test.ts create mode 100644 packages/description-renderer/test/offline.test.ts create mode 100644 packages/description-renderer/test/rules.test.ts create mode 100644 packages/description-renderer/test/seam.test.ts create mode 100644 packages/description-renderer/test/security.test.ts create mode 100644 packages/description-renderer/tsconfig.json diff --git a/packages/description-renderer/LICENSE.txt b/packages/description-renderer/LICENSE.txt new file mode 100644 index 00000000..b2f52a2b --- /dev/null +++ b/packages/description-renderer/LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/description-renderer/README.md b/packages/description-renderer/README.md new file mode 100644 index 00000000..5fa426b5 --- /dev/null +++ b/packages/description-renderer/README.md @@ -0,0 +1,119 @@ +# codetour-description-renderer + +Offline, strict-security Mermaid rendering for [CodeTour](https://github.com/microsoft/codetour) +Markdown descriptions. The renderer is the shared description-to-comment-content +seam: it accepts a complete Markdown description plus the effective VS Code theme +and returns the final Markdown used by a native VS Code comment, with each +`mermaid` fence replaced by an in-memory PNG image or a compact warning. + +Design constraints: + +- **Offline** — no font, image, script or config fetches; rendering works with + network access blocked. +- **Strict security** — Mermaid runs with `securityLevel: "strict"` and + `htmlLabels: false`; the intermediate SVG is additionally sanitized (scripts, + event handlers, `foreignObject`, anchors, remote-resource elements and all + external references removed) as defense in depth. +- **In-memory transport** — Mermaid produces a theme-aware SVG internally, which + is rasterized to PNG with `@resvg/resvg-js` and emitted as a + `data:image/png;base64` image; SVG data URIs did not display as images in + native comments. No generated SVG or PNG file is ever written. +- **No generated assets** — the Mermaid source stays in the tour file; nothing + is committed or persisted. +- **Reuse** — rendered diagrams are reused only in memory when their exact + source, effective theme, renderer version and rendering options match. A + theme change can call `clearMermaidRenderCache()` to discard all entries; + failures are never kept in the cache. The MCP server (`packages/mcp-server`) + will validate Mermaid with the same exact, locked Mermaid version and diagram + rules through this package. + +## Diagram rules + +The rules below are implemented in `src/rules.ts` and are the single source of +truth shared by playback and (later) MCP validation. They are deliberately +syntactic and dependency-free so both sides evaluate them identically. + +- **Fence**: a Mermaid fence is a block whose opening line matches + `^\s*```mermaid[ \t]*$` and whose closing line is the next line that is only + backticks (`^\s*```[ \t]*$`). A `mermaid` line nested inside another fence is + not a Mermaid fence. +- **Caption**: the nearest non-blank line above the fence's opening line must + be a caption: `^\s*\*\*(Diagram — …)\*\*[ \t]*$`, where `…` is one or more + characters that do not contain `**`. Blank lines may sit between the caption + and the fence; any other content breaks the association. The caption stays + visible and becomes the image alternative text. +- **Allowlist**: the diagram kind is the first whitespace-delimited token of + the first significant source line (blank lines and `%%` comment/init-directive + lines are skipped). Exactly `flowchart`, `sequenceDiagram`, + `stateDiagram-v2`, `classDiagram` and `erDiagram` are supported; aliases such + as `graph` or `stateDiagram` are not. +- **Count limit**: at most 3 Mermaid fences per description, counted in + document order. During playback the first three fences are evaluated and each + fence from the fourth on fails locally with a warning, regardless of its + content. Validation (MCP) rejects the whole description when it contains more + than 3 fences. +- **Size limit**: the UTF-8 byte length of the source (the exact text between + the opening and closing fence lines) must be at most 20 KB (20480 bytes). +- **Per-fence evaluation order**: caption, then size, then kind. The first + violated rule decides the failure. Diagrams in one description are evaluated + and rendered independently, so one rejected diagram never hides its siblings. +- **Failures**: a rejected or unrenderable diagram is replaced by a single + compact warning line and never by its source. `renderMermaidDiagram` also + refuses unsupported kinds on its own, so the low-level API is safe to call + directly. + +## API + +```ts +type DescriptionTheme = "light" | "dark"; + +renderDescription(description: string, theme: DescriptionTheme): Promise; +makeRenderedImagesResponsive(markdown: string): string; +renderMermaidDiagram(source: string, theme: DescriptionTheme): Promise<{ svg: string; png: Buffer }>; +clearMermaidRenderCache(): void; +invalidateMermaidRenderCache(): void; // alias used by theme-change callers +sanitizeSvg(svg: string): string; +``` + +`renderDescription` is the shared Markdown transformation used by playback +surfaces. Its diagram work is backed by the in-memory cache; no cache entry or +generated image is written to the workspace or persisted in VS Code state. +`makeRenderedImagesResponsive` can then convert Markdown images into safe HTML +images with `width="100%"`, which lets native VS Code comments and preview +surfaces adapt them to their available width. Markdown image titles are +preserved as HTML `title` attributes. +Preview callers that create a `MarkdownString` must set `supportHtml = true` +for this HTML subset to be rendered; edit-mode comments should keep it off. +`DESCRIPTION_RENDERER_VERSION` identifies the output contract represented by +the cache. + +Shared rule surface (for playback and MCP validation): + +```ts +ALLOWED_DIAGRAM_KINDS: readonly ["flowchart", "sequenceDiagram", "stateDiagram-v2", "classDiagram", "erDiagram"]; +MAX_DIAGRAMS_PER_DESCRIPTION: 3; +MAX_DIAGRAM_SOURCE_BYTES: 20480; + +findDiagramFences(description: string): DiagramFence[]; // caption?, source, start, end +matchDiagramCaption(line: string): string | undefined; +diagramKindOf(source: string): string | undefined; +diagramSourceByteLength(source: string): number; +isAllowedDiagramKind(kind: string): boolean; +isMermaidFenceInfo(info: string): boolean; +evaluateDiagramFence(fence: { caption?: string; source: string }): DiagramFenceEvaluation; +``` + +## Development + +From the repository root: + +```bash +npm ci --prefix packages/description-renderer +npm test --prefix packages/description-renderer +``` + +Dependencies are exact-pinned (`mermaid` 11.12.2, `jsdom` 26.1.0, +`@resvg/resvg-js` 2.6.2) so every consumer renders with the same locked +versions. JSDOM has no real SVG layout: text measurement relies on bounded +heuristics (`getBBox`, `getComputedTextLength`, `getBoundingClientRect` +polyfills), validated against all five allowed diagram kinds. diff --git a/packages/description-renderer/package-lock.json b/packages/description-renderer/package-lock.json new file mode 100644 index 00000000..6807af07 --- /dev/null +++ b/packages/description-renderer/package-lock.json @@ -0,0 +1,1990 @@ +{ + "name": "codetour-description-renderer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codetour-description-renderer", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@resvg/resvg-js": "2.6.2", + "jsdom": "26.1.0", + "mermaid": "11.12.2" + }, + "devDependencies": { + "@types/jsdom": "27.0.0", + "@types/node": "24.13.3", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", + "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/@chevrotain/gast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", + "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/gast/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "license": "Apache-2.0" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", + "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "license": "MIT", + "dependencies": { + "langium": "3.3.1" + } + }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/jsdom": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "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/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/chevrotain": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", + "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, + "node_modules/chevrotain/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cytoscape": { + "version": "3.34.2", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.2.tgz", + "integrity": "sha512-Cm2jaj1X/PBNlzV9yH8zcfGOxO7U+CJ/+mxSBVPSchLaugdp4jtlGx5qaHtPRZ6tgiZ5P+o1XoRfJA+ba6KM3g==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", + "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/langium": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", + "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.0.3", + "chevrotain-allstar": "~0.3.0", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.0.8" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mermaid": { + "version": "11.12.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz", + "integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.1", + "@mermaid-js/parser": "^0.6.3", + "@types/d3": "^7.4.3", + "cytoscape": "^3.29.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.13", + "dayjs": "^1.11.18", + "dompurify": "^3.2.5", + "katex": "^0.16.22", + "khroma": "^2.1.0", + "lodash-es": "^4.17.21", + "marked": "^16.2.1", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "license": "MIT" + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT" + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "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==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "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/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz", + "integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + } + } +} diff --git a/packages/description-renderer/package.json b/packages/description-renderer/package.json new file mode 100644 index 00000000..e6955d14 --- /dev/null +++ b/packages/description-renderer/package.json @@ -0,0 +1,39 @@ +{ + "name": "codetour-description-renderer", + "version": "0.1.0", + "description": "Offline, strict-security Mermaid rendering that turns CodeTour Markdown descriptions into native comment content", + "license": "MIT", + "author": { + "name": "Microsoft Corporation" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/codetour", + "directory": "packages/description-renderer" + }, + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "files": [ + "dist/src", + "README.md", + "LICENSE.txt" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "npm run build && node ../../scripts/run-compiled-tests.js dist/test" + }, + "dependencies": { + "@resvg/resvg-js": "2.6.2", + "jsdom": "26.1.0", + "mermaid": "11.12.2" + }, + "devDependencies": { + "@types/jsdom": "27.0.0", + "@types/node": "24.13.3", + "typescript": "5.9.3" + } +} diff --git a/packages/description-renderer/src/description.ts b/packages/description-renderer/src/description.ts new file mode 100644 index 00000000..28affd63 --- /dev/null +++ b/packages/description-renderer/src/description.ts @@ -0,0 +1,146 @@ +import { findDiagramFences } from "./parse"; +import { + DescriptionTheme, + renderMermaidDiagram +} from "./render"; +import { + DiagramRuleReason, + MAX_DIAGRAMS_PER_DESCRIPTION, + evaluateDiagramFence +} from "./rules"; + +const RENDER_FAILURE_NOTICES: Record = { + caption: + "> ⚠️ The Mermaid diagram requires an immediately preceding **Diagram — …** caption.", + size: "> ⚠️ The Mermaid diagram source exceeds the 20 KB limit.", + kind: + "> ⚠️ Unsupported Mermaid diagram kind (supported: flowchart, sequenceDiagram, stateDiagram-v2, classDiagram, erDiagram).", + count: + "> ⚠️ A description accepts at most three Mermaid diagrams; this one was not rendered.", + render: "> ⚠️ The Mermaid diagram could not be rendered." +}; + +const MARKDOWN_IMAGE_PATTERN = + /!\[((?:\\.|[^\]])*)\]\(((?:\\.|[^\s)])+)(?:\s+((?:"(?:\\.|[^"])*")|'(?:\\.|[^'])*'))?\)/g; +const MARKDOWN_ALT_ESCAPE_PATTERN = /\\([[\]])/g; + +function escapeAltText(caption: string): string { + return caption.replace(/([[\]])/g, "\\$1"); +} + +function escapeHtmlAttribute(value: string): string { + return value.replace(/[&<>"']/g, character => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +/** + * VS Code's native Markdown renderer supports a safe HTML subset, including + * percentage image widths. Convert Markdown images to that subset so they + * use the available width of comments and preview surfaces. + */ +export function makeRenderedImagesResponsive(markdown: string): string { + const replaceImages = (content: string): string => + content.replace( + MARKDOWN_IMAGE_PATTERN, + ( + _, + escapedAltText: string, + escapedDestination: string, + quotedTitle: string | undefined + ) => { + const altText = escapedAltText.replace( + MARKDOWN_ALT_ESCAPE_PATTERN, + "$1" + ); + const destination = escapedDestination.replace(/\\([()\\])/g, "$1"); + const title = quotedTitle + ? quotedTitle + .slice(1, -1) + .replace(/\\([\\"'])/g, "$1") + : undefined; + return ( + '' +
+          escapeHtmlAttribute(altText) +
+          '' + ); + } + ); + + let fence: { character: string; length: number } | undefined; + return markdown + .split("\n") + .map(line => { + const marker = line.match(/^\s*([\x60]{3,}|~{3,})/); + if (marker) { + const character = marker[1][0]; + if (!fence) { + fence = { character, length: marker[1].length }; + } else if ( + fence.character === character && + marker[1].length >= fence.length + ) { + fence = undefined; + } + return line; + } + + return fence ? line : replaceImages(line); + }) + .join("\n"); +} + +export async function renderDescription( + description: string, + theme: DescriptionTheme +): Promise { + const fences = findDiagramFences(description); + if (fences.length === 0) { + return description; + } + + let content = description; + for (let index = fences.length - 1; index >= 0; index--) { + const fence = fences[index]; + let replacement: string; + + if (index >= MAX_DIAGRAMS_PER_DESCRIPTION) { + replacement = RENDER_FAILURE_NOTICES.count; + } else if (!fence.closed) { + replacement = RENDER_FAILURE_NOTICES.render; + } else { + const evaluation = evaluateDiagramFence(fence); + if (!evaluation.allowed) { + replacement = RENDER_FAILURE_NOTICES[evaluation.reason]; + } else { + try { + const { png } = await renderMermaidDiagram(fence.source, theme); + replacement = `![${escapeAltText( + fence.caption! + )}](data:image/png;base64,${png.toString("base64")})`; + } catch { + replacement = RENDER_FAILURE_NOTICES.render; + } + } + } + + content = + content.slice(0, fence.start) + replacement + content.slice(fence.end); + } + + return content; +} diff --git a/packages/description-renderer/src/dom.ts b/packages/description-renderer/src/dom.ts new file mode 100644 index 00000000..20cd866b --- /dev/null +++ b/packages/description-renderer/src/dom.ts @@ -0,0 +1,518 @@ +import { JSDOM } from "jsdom"; +import { + DEFAULT_FONT_SIZE, + LINE_HEIGHT_FACTOR, + measureTextWidth +} from "./measure"; + +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + +const DOM_GLOBAL_KEYS = [ + "window", + "self", + "document", + "navigator", + "location", + "history", + "getComputedStyle", + "DOMParser", + "XMLSerializer", + "Node", + "Element", + "HTMLElement", + "HTMLAnchorElement", + "HTMLImageElement", + "Image", + "SVGElement", + "MutationObserver", + "CSSStyleDeclaration" +]; + +const NON_RENDERED_ELEMENTS = new Set([ + "defs", + "style", + "title", + "desc", + "metadata", + "clipPath", + "mask", + "pattern", + "symbol", + "marker", + "linearGradient", + "radialGradient", + "filter", + "script" +]); + +interface Box { + x: number; + y: number; + width: number; + height: number; +} + +interface Matrix { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; +} + +const IDENTITY: Matrix = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + +let environment: { window: DomWindow } | undefined; + +export interface DomWindow { + document: Document; + DOMParser: typeof DOMParser; + XMLSerializer: typeof XMLSerializer; + SVGElement: typeof SVGElement; + close(): void; +} + +export function ensureDomEnvironment(): DomWindow { + if (environment) { + return environment.window; + } + + const dom = new JSDOM("", { + pretendToBeVisual: false, + url: "https://codetour.invalid/" + }); + + installSvgMeasurement(dom.window); + + environment = dom; + + const globals = globalThis as unknown as Record; + const domGlobals = dom.window as unknown as Record; + for (const key of DOM_GLOBAL_KEYS) { + if (domGlobals[key] === undefined) { + continue; + } + Object.defineProperty(globals, key, { + value: domGlobals[key], + configurable: true, + writable: true, + enumerable: true + }); + } + + return dom.window; +} + +function installSvgMeasurement(window: DomWindow) { + const prototype = window.SVGElement && window.SVGElement.prototype; + if (!prototype) { + return; + } + + const svgPrototype = prototype as unknown as Record; + + if (typeof svgPrototype.getBBox !== "function") { + svgPrototype.getBBox = function(this: Element): Box { + return measureElementBox(this); + }; + } + + if (typeof svgPrototype.getComputedTextLength !== "function") { + svgPrototype.getComputedTextLength = function(this: Element): number { + return measureTextWidth(this.textContent || "", fontSizeOf(this)); + }; + } + + svgPrototype.getBoundingClientRect = function(this: Element) { + const box = measureTextBox(this); + return { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + top: box.y, + left: box.x, + right: box.x + box.width, + bottom: box.y + box.height, + toJSON() { + return box; + } + }; + }; +} + +function coordinateOf(value: string | null, fontSize: number): number | undefined { + if (value === null) { + return undefined; + } + const parsed = parseFloat(value); + if (isNaN(parsed)) { + return undefined; + } + if (value.trim().endsWith("em")) { + return parsed * fontSize; + } + return parsed; +} + +function fontSizeOf(element: Element): number { + let current: Element | null = element; + while (current) { + const attribute = coordinateOf(current.getAttribute("font-size"), DEFAULT_FONT_SIZE); + if (attribute !== undefined) { + return attribute; + } + const style = current.getAttribute("style"); + if (style) { + const match = style.match(/font-size:\s*([\d.]+)(px|em)?/); + if (match) { + const size = parseFloat(match[1]); + return match[2] === "em" ? size * DEFAULT_FONT_SIZE : size; + } + } + current = current.parentElement; + } + return DEFAULT_FONT_SIZE; +} + +function measureTextBox(element: Element): Box { + const fontSize = fontSizeOf(element); + const x = coordinateOf(element.getAttribute("x"), fontSize) || 0; + const tspans = Array.from(element.children).filter( + child => child.localName === "tspan" + ); + + let width = 0; + let top = Infinity; + let bottom = -Infinity; + + const measureLine = (text: string, y: number) => { + width = Math.max(width, measureTextWidth(text, fontSize)); + top = Math.min(top, y - fontSize * 0.85); + bottom = Math.max(bottom, y + fontSize * 0.3); + }; + + if (tspans.length > 0) { + let lineY = coordinateOf(element.getAttribute("y"), fontSize) || 0; + for (const tspan of tspans) { + const explicitY = coordinateOf(tspan.getAttribute("y"), fontSize); + const dy = coordinateOf(tspan.getAttribute("dy"), fontSize); + if (explicitY !== undefined) { + lineY = explicitY; + } else if (dy !== undefined) { + lineY += dy; + } else { + lineY += fontSize * LINE_HEIGHT_FACTOR; + } + measureLine(tspan.textContent || "", lineY); + } + } else { + measureLine( + element.textContent || "", + coordinateOf(element.getAttribute("y"), fontSize) || 0 + ); + } + + if (top === Infinity) { + top = -fontSize; + bottom = fontSize * 0.3; + } + + return { + x, + y: top, + width, + height: Math.max(bottom - top, fontSize * LINE_HEIGHT_FACTOR) + }; +} + +function union(boxes: Box[]): Box { + const minX = Math.min(...boxes.map(box => box.x)); + const minY = Math.min(...boxes.map(box => box.y)); + const maxX = Math.max(...boxes.map(box => box.x + box.width)); + const maxY = Math.max(...boxes.map(box => box.y + box.height)); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + +function multiplyMatrices(first: Matrix, second: Matrix): Matrix { + return { + a: first.a * second.a + first.c * second.b, + b: first.b * second.a + first.d * second.b, + c: first.a * second.c + first.c * second.d, + d: first.b * second.c + first.d * second.d, + e: first.a * second.e + first.c * second.f + first.e, + f: first.b * second.e + first.d * second.f + first.f + }; +} + +function parseTransform(value: string | null): Matrix { + if (!value) { + return IDENTITY; + } + + let result = IDENTITY; + const pattern = /(translate|scale|rotate|matrix)\s*\(([^)]*)\)/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(value))) { + const args = match[2] + .split(/[\s,]+/) + .map(Number) + .filter(number => !isNaN(number)); + + let transform: Matrix; + if (match[1] === "translate") { + transform = { a: 1, b: 0, c: 0, d: 1, e: args[0] || 0, f: args[1] || 0 }; + } else if (match[1] === "scale") { + const scaleX = args[0] === undefined ? 1 : args[0]; + const scaleY = args[1] === undefined ? scaleX : args[1]; + transform = { a: scaleX, b: 0, c: 0, d: scaleY, e: 0, f: 0 }; + } else if (match[1] === "rotate") { + const angle = ((args[0] || 0) * Math.PI) / 180; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const rotation = { a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 }; + if (args.length > 2) { + transform = multiplyMatrices( + multiplyMatrices({ a: 1, b: 0, c: 0, d: 1, e: args[1], f: args[2] }, rotation), + { a: 1, b: 0, c: 0, d: 1, e: -args[1], f: -args[2] } + ); + } else { + transform = rotation; + } + } else { + transform = { + a: args[0] || 1, + b: args[1] || 0, + c: args[2] || 0, + d: args[3] || 1, + e: args[4] || 0, + f: args[5] || 0 + }; + } + + result = multiplyMatrices(result, transform); + } + + return result; +} + +function transformBox(box: Box, matrix: Matrix): Box { + const points = [ + { x: box.x, y: box.y }, + { x: box.x + box.width, y: box.y }, + { x: box.x, y: box.y + box.height }, + { x: box.x + box.width, y: box.y + box.height } + ].map(point => ({ + x: matrix.a * point.x + matrix.c * point.y + matrix.e, + y: matrix.b * point.x + matrix.d * point.y + matrix.f + })); + + return union(points.map(point => ({ ...point, width: 0, height: 0 }))); +} + +function measureElementBox(element: Element, inherited: Matrix = IDENTITY): Box { + const matrix = multiplyMatrices( + inherited, + parseTransform(element.getAttribute("transform")) + ); + + const tag = element.localName; + + if (tag === "text" || tag === "tspan") { + return transformBox(measureTextBox(element), matrix); + } + + if (tag === "rect") { + return transformBox( + { + x: coordinateOf(element.getAttribute("x"), DEFAULT_FONT_SIZE) || 0, + y: coordinateOf(element.getAttribute("y"), DEFAULT_FONT_SIZE) || 0, + width: coordinateOf(element.getAttribute("width"), DEFAULT_FONT_SIZE) || 0, + height: coordinateOf(element.getAttribute("height"), DEFAULT_FONT_SIZE) || 0 + }, + matrix + ); + } + + if (tag === "circle") { + const r = coordinateOf(element.getAttribute("r"), DEFAULT_FONT_SIZE) || 0; + const cx = coordinateOf(element.getAttribute("cx"), DEFAULT_FONT_SIZE) || 0; + const cy = coordinateOf(element.getAttribute("cy"), DEFAULT_FONT_SIZE) || 0; + return transformBox({ x: cx - r, y: cy - r, width: r * 2, height: r * 2 }, matrix); + } + + if (tag === "ellipse") { + const rx = coordinateOf(element.getAttribute("rx"), DEFAULT_FONT_SIZE) || 0; + const ry = coordinateOf(element.getAttribute("ry"), DEFAULT_FONT_SIZE) || 0; + const cx = coordinateOf(element.getAttribute("cx"), DEFAULT_FONT_SIZE) || 0; + const cy = coordinateOf(element.getAttribute("cy"), DEFAULT_FONT_SIZE) || 0; + return transformBox({ x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2 }, matrix); + } + + if (tag === "line") { + const x1 = coordinateOf(element.getAttribute("x1"), DEFAULT_FONT_SIZE) || 0; + const y1 = coordinateOf(element.getAttribute("y1"), DEFAULT_FONT_SIZE) || 0; + const x2 = coordinateOf(element.getAttribute("x2"), DEFAULT_FONT_SIZE) || 0; + const y2 = coordinateOf(element.getAttribute("y2"), DEFAULT_FONT_SIZE) || 0; + return transformBox( + union([ + { x: x1, y: y1, width: 0, height: 0 }, + { x: x2, y: y2, width: 0, height: 0 } + ]), + matrix + ); + } + + if (tag === "path") { + return pathBox(element, matrix); + } + + const boxes = Array.from(element.children) + .filter(child => !NON_RENDERED_ELEMENTS.has(child.localName)) + .map(child => measureElementBox(child, matrix)); + if (boxes.length === 0) { + return transformBox({ x: 0, y: 0, width: 0, height: 0 }, matrix); + } + return union(boxes); +} + +function pathBox(element: Element, matrix: Matrix): Box { + const commands = element.getAttribute("d") || ""; + const pattern = /([MLCQTAZmlcqtaz])([^MLCQTAZmlcqtaz]*)/g; + let currentX = 0; + let currentY = 0; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + const track = (x: number, y: number) => { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + }; + + let match: RegExpExecArray | null; + while ((match = pattern.exec(commands))) { + const command = match[1]; + const args = match[2] + .trim() + .split(/[\s,]+/) + .filter(part => part !== "") + .map(Number) + .filter(number => !isNaN(number)); + + if (command === "M" || command === "L" || command === "T") { + for (let index = 0; index + 1 < args.length; index += 2) { + currentX = args[index]; + currentY = args[index + 1]; + track(currentX, currentY); + } + } else if (command === "m" || command === "l" || command === "t") { + for (let index = 0; index + 1 < args.length; index += 2) { + currentX += args[index]; + currentY += args[index + 1]; + track(currentX, currentY); + } + } else if (command === "C") { + for (let index = 0; index + 5 < args.length; index += 6) { + track(args[index], args[index + 1]); + track(args[index + 2], args[index + 3]); + currentX = args[index + 4]; + currentY = args[index + 5]; + track(currentX, currentY); + } + } else if (command === "c") { + for (let index = 0; index + 5 < args.length; index += 6) { + track(currentX + args[index], currentY + args[index + 1]); + track(currentX + args[index + 2], currentY + args[index + 3]); + currentX += args[index + 4]; + currentY += args[index + 5]; + track(currentX, currentY); + } + } else if (command === "Q" || command === "S") { + for (let index = 0; index + 3 < args.length; index += 4) { + track(args[index], args[index + 1]); + currentX = args[index + 2]; + currentY = args[index + 3]; + track(currentX, currentY); + } + } else if (command === "q" || command === "s") { + for (let index = 0; index + 3 < args.length; index += 4) { + track(currentX + args[index], currentY + args[index + 1]); + currentX += args[index + 2]; + currentY += args[index + 3]; + track(currentX, currentY); + } + } else if (command === "H") { + for (const value of args) { + currentX = value; + track(currentX, currentY); + } + } else if (command === "h") { + for (const value of args) { + currentX += value; + track(currentX, currentY); + } + } else if (command === "V") { + for (const value of args) { + currentY = value; + track(currentX, currentY); + } + } else if (command === "v") { + for (const value of args) { + currentY += value; + track(currentX, currentY); + } + } else if (command === "A") { + for (let index = 5; index < args.length; index += 7) { + currentX = args[index]; + currentY = args[index + 1]; + track(currentX, currentY); + } + } else if (command === "a") { + for (let index = 5; index < args.length; index += 7) { + currentX += args[index]; + currentY += args[index + 1]; + track(currentX, currentY); + } + } + } + + if (minX === Infinity) { + return transformBox({ x: 0, y: 0, width: 0, height: 0 }, matrix); + } + + return transformBox( + { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, + matrix + ); +} + +export function measureContentBounds(root: Element): Box { + const boxes = Array.from(root.children) + .filter(child => !NON_RENDERED_ELEMENTS.has(child.localName)) + .map(child => measureElementBox(child)); + if (boxes.length === 0) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + return union(boxes); +} + +export function parseSvgDocument(svg: string): Document { + const window = ensureDomEnvironment(); + const document = new window.DOMParser().parseFromString(svg, "image/svg+xml"); + if (document.documentElement.localName !== "svg") { + throw new Error("The rendered diagram is not a valid SVG document"); + } + return document; +} + +export function serializeSvgDocument(document: Document): string { + const window = ensureDomEnvironment(); + return new window.XMLSerializer().serializeToString(document.documentElement); +} + +export { SVG_NAMESPACE }; diff --git a/packages/description-renderer/src/index.ts b/packages/description-renderer/src/index.ts new file mode 100644 index 00000000..6b66f3ac --- /dev/null +++ b/packages/description-renderer/src/index.ts @@ -0,0 +1,31 @@ +export { + makeRenderedImagesResponsive, + renderDescription +} from "./description"; +export { + clearMermaidRenderCache, + DESCRIPTION_RENDERER_VERSION, + invalidateMermaidRenderCache, + renderMermaidDiagram +} from "./render"; +export type { DescriptionTheme, RenderedDiagram } from "./render"; +export { sanitizeSvg } from "./sanitize"; +export { findDiagramFences } from "./parse"; +export type { DiagramFence } from "./parse"; +export { + ALLOWED_DIAGRAM_KINDS, + MAX_DIAGRAMS_PER_DESCRIPTION, + MAX_DIAGRAM_SOURCE_BYTES, + diagramKindOf, + diagramSourceByteLength, + evaluateDiagramFence, + isAllowedDiagramKind, + isMermaidFenceInfo, + matchDiagramCaption +} from "./rules"; +export type { + AllowedDiagramKind, + DiagramFenceEvaluation, + DiagramFenceInput, + DiagramRuleReason +} from "./rules"; diff --git a/packages/description-renderer/src/measure.ts b/packages/description-renderer/src/measure.ts new file mode 100644 index 00000000..3174fa97 --- /dev/null +++ b/packages/description-renderer/src/measure.ts @@ -0,0 +1,66 @@ +const NARROW_CHARACTERS = new Set([ + "i", + "I", + "l", + "j", + "t", + "f", + ".", + ",", + ":", + ";", + "'", + "`", + "|", + "!", + "(", + ")", + "[", + "]", + "{", + "}", + "/", + "\\", + "-" +]); + +const WIDE_CHARACTERS = new Set(["m", "M", "w", "W", "@", "%", "&"]); + +export const DEFAULT_FONT_SIZE = 16; +export const LINE_HEIGHT_FACTOR = 1.25; + +function characterWidthFactor(character: string): number { + if (character === " ") { + return 0.3; + } + if (character === "\t") { + return 1.2; + } + if (NARROW_CHARACTERS.has(character)) { + return 0.32; + } + if (WIDE_CHARACTERS.has(character)) { + return 0.95; + } + if (character >= "A" && character <= "Z") { + return 0.72; + } + if (character >= "0" && character <= "9") { + return 0.58; + } + if (character === "—" || character === "–") { + return 1; + } + if (character.charCodeAt(0) > 0x2000) { + return 1; + } + return 0.55; +} + +export function measureTextWidth(text: string, fontSize: number): number { + let width = 0; + for (const character of text) { + width += characterWidthFactor(character); + } + return width * fontSize; +} diff --git a/packages/description-renderer/src/parse.ts b/packages/description-renderer/src/parse.ts new file mode 100644 index 00000000..ce613641 --- /dev/null +++ b/packages/description-renderer/src/parse.ts @@ -0,0 +1,81 @@ +import { isMermaidFenceInfo, matchDiagramCaption } from "./rules"; + +const FENCE_INFO_PATTERN = /^\s*```(.*)$/; +const FENCE_CLOSE_PATTERN = /^\s*```[ \t]*$/; +const FENCE_OPEN_PATTERN = /^\s*```/; + +export interface DiagramFence { + caption?: string; + source: string; + closed: boolean; + start: number; + end: number; +} + +function findCaption(lines: string[], fenceIndex: number): string | undefined { + for (let index = fenceIndex - 1; index >= 0; index--) { + const line = lines[index]; + if (line.trim() === "") { + continue; + } + return matchDiagramCaption(line); + } + return undefined; +} + +export function findDiagramFences(description: string): DiagramFence[] { + const lines = description.split("\n"); + const lineOffsets: number[] = []; + let offset = 0; + for (const line of lines) { + lineOffsets.push(offset); + offset += line.length + 1; + } + + const fences: DiagramFence[] = []; + + let index = 0; + while (index < lines.length) { + if (!FENCE_OPEN_PATTERN.test(lines[index])) { + index++; + continue; + } + + let closeIndex = -1; + for (let candidate = index + 1; candidate < lines.length; candidate++) { + if (FENCE_CLOSE_PATTERN.test(lines[candidate])) { + closeIndex = candidate; + break; + } + } + + if (closeIndex === -1) { + const infoMatch = lines[index].match(FENCE_INFO_PATTERN); + if (infoMatch && isMermaidFenceInfo(infoMatch[1])) { + fences.push({ + caption: findCaption(lines, index), + source: lines.slice(index + 1).join("\n"), + closed: false, + start: lineOffsets[index], + end: description.length + }); + } + break; + } + + const infoMatch = lines[index].match(FENCE_INFO_PATTERN); + if (infoMatch && isMermaidFenceInfo(infoMatch[1])) { + fences.push({ + caption: findCaption(lines, index), + source: lines.slice(index + 1, closeIndex).join("\n"), + closed: true, + start: lineOffsets[index], + end: lineOffsets[closeIndex] + lines[closeIndex].length + }); + } + + index = closeIndex + 1; + } + + return fences; +} diff --git a/packages/description-renderer/src/rasterize.ts b/packages/description-renderer/src/rasterize.ts new file mode 100644 index 00000000..a5b0a7f4 --- /dev/null +++ b/packages/description-renderer/src/rasterize.ts @@ -0,0 +1,21 @@ +import { Resvg } from "@resvg/resvg-js"; + +const MAX_RASTERIZED_WIDTH = 2000; + +export function rasterizeSvg( + svg: string, + naturalWidth: number, + maxRasterizedWidth: number = MAX_RASTERIZED_WIDTH +): Buffer { + const resvg = new Resvg(svg, { + font: { + loadSystemFonts: true + }, + fitTo: + naturalWidth > maxRasterizedWidth + ? { mode: "width", value: maxRasterizedWidth } + : { mode: "original" } + }); + + return resvg.render().asPng(); +} diff --git a/packages/description-renderer/src/render.ts b/packages/description-renderer/src/render.ts new file mode 100644 index 00000000..b9b89616 --- /dev/null +++ b/packages/description-renderer/src/render.ts @@ -0,0 +1,218 @@ +import { ensureDomEnvironment, measureContentBounds } from "./dom"; +import { rasterizeSvg } from "./rasterize"; +import { sanitizeSvg } from "./sanitize"; +import { diagramKindOf, isAllowedDiagramKind } from "./rules"; + +export type DescriptionTheme = "light" | "dark"; + +export interface RenderedDiagram { + svg: string; + png: Buffer; +} + +/** + * Bump this when the renderer's output contract changes. The value is part of + * the in-memory cache key so a long-lived extension host never reuses output + * produced by an older renderer implementation. + */ +export const DESCRIPTION_RENDERER_VERSION = "0.1.0"; + +interface MermaidRenderResult { + svg: string; +} + +interface MermaidApi { + initialize(config: unknown): void; + parse(text: string): Promise; + render(id: string, text: string): Promise; +} + +const MERMAID_THEMES: Record = { + light: "default", + dark: "dark" +}; + +const MERMAID_RENDER_OPTIONS = Object.freeze({ + securityLevel: "strict", + htmlLabels: false, + flowchartHtmlLabels: false, + rasterizer: "@resvg/resvg-js@2.6.2", + maxRasterizedWidth: 2000, + viewBoxPadding: 2 +} as const); + +let mermaid: MermaidApi | undefined; +let mermaidLoad: Promise | undefined; +let diagramCounter = 0; +const renderedDiagramCache = new Map>(); +let renderQueue = Promise.resolve(); + +async function loadMermaid(): Promise { + const imported = await import("mermaid"); + return imported.default as unknown as MermaidApi; +} + +async function getMermaid(): Promise { + if (mermaid) { + return mermaid; + } + + const load = (mermaidLoad ??= loadMermaid()); + try { + mermaid = await load; + return mermaid; + } catch (error) { + if (mermaidLoad === load) { + mermaidLoad = undefined; + } + throw error; + } +} + +function mermaidConfiguration(theme: DescriptionTheme) { + return { + startOnLoad: false, + securityLevel: MERMAID_RENDER_OPTIONS.securityLevel as "strict", + theme: MERMAID_THEMES[theme], + htmlLabels: MERMAID_RENDER_OPTIONS.htmlLabels, + flowchart: { + htmlLabels: MERMAID_RENDER_OPTIONS.flowchartHtmlLabels + } + }; +} + +function normalizeSvgSize( + svg: string, + viewBoxPadding: number +): { svg: string; width: number } { + const window = ensureDomEnvironment(); + const document = new window.DOMParser().parseFromString(svg, "image/svg+xml"); + const root = document.documentElement; + + const viewBox = (root.getAttribute("viewBox") || "") + .split(/[\s,]+/) + .map(part => Number(part)) + .filter(part => !isNaN(part)); + + let bounds = { + x: 0, + y: 0, + width: 0, + height: 0 + }; + + if (viewBox.length === 4) { + bounds = { x: viewBox[0], y: viewBox[1], width: viewBox[2], height: viewBox[3] }; + } else { + bounds.width = Number(root.getAttribute("width")) || 800; + bounds.height = Number(root.getAttribute("height")) || 600; + } + + const content = measureContentBounds(root); + bounds = { + x: Math.min(bounds.x, content.x - viewBoxPadding), + y: Math.min(bounds.y, content.y - viewBoxPadding), + width: 0, + height: 0 + }; + bounds.width = Math.max( + viewBox.length === 4 ? viewBox[0] + viewBox[2] : bounds.width, + content.x + content.width + viewBoxPadding + ) - bounds.x; + bounds.height = Math.max( + viewBox.length === 4 ? viewBox[1] + viewBox[3] : bounds.height, + content.y + content.height + viewBoxPadding + ) - bounds.y; + + root.setAttribute( + "viewBox", + `${bounds.x} ${bounds.y} ${Math.ceil(bounds.width)} ${Math.ceil(bounds.height)}` + ); + root.setAttribute("width", String(Math.ceil(bounds.width))); + root.setAttribute("height", String(Math.ceil(bounds.height))); + root.removeAttribute("style"); + + const normalized = new window.XMLSerializer().serializeToString(root); + return { svg: normalized, width: bounds.width }; +} + +async function renderMermaidDiagramUncached( + source: string, + theme: DescriptionTheme +): Promise { + const render = async () => { + const kind = diagramKindOf(source); + if (!kind || !isAllowedDiagramKind(kind)) { + throw new Error( + `Unsupported Mermaid diagram kind: ${kind ?? "(none detected)"}` + ); + } + + ensureDomEnvironment(); + + const renderer = await getMermaid(); + renderer.initialize(mermaidConfiguration(theme)); + await renderer.parse(source); + const { svg } = await renderer.render( + `codetour-diagram-${++diagramCounter}`, + source + ); + + const sanitized = sanitizeSvg(svg); + const normalized = normalizeSvgSize( + sanitized, + MERMAID_RENDER_OPTIONS.viewBoxPadding + ); + const png = rasterizeSvg( + normalized.svg, + normalized.width, + MERMAID_RENDER_OPTIONS.maxRasterizedWidth + ); + + return { svg: normalized.svg, png }; + }; + + const queued = renderQueue.then(render, render); + renderQueue = queued.then( + () => undefined, + () => undefined + ); + return queued; +} + +function renderCacheKey(source: string, theme: DescriptionTheme): string { + return JSON.stringify({ + source, + theme, + rendererVersion: DESCRIPTION_RENDERER_VERSION, + options: MERMAID_RENDER_OPTIONS + }); +} + +/** Clear all in-memory output, normally after VS Code changes its theme. */ +export function clearMermaidRenderCache(): void { + renderedDiagramCache.clear(); +} + +export const invalidateMermaidRenderCache = clearMermaidRenderCache; + +export function renderMermaidDiagram( + source: string, + theme: DescriptionTheme +): Promise { + const key = renderCacheKey(source, theme); + const cached = renderedDiagramCache.get(key); + if (cached) { + return cached; + } + + const rendered = renderMermaidDiagramUncached(source, theme); + renderedDiagramCache.set(key, rendered); + void rendered.catch(() => { + if (renderedDiagramCache.get(key) === rendered) { + renderedDiagramCache.delete(key); + } + }); + + return rendered; +} diff --git a/packages/description-renderer/src/rules.ts b/packages/description-renderer/src/rules.ts new file mode 100644 index 00000000..00e0dccb --- /dev/null +++ b/packages/description-renderer/src/rules.ts @@ -0,0 +1,76 @@ +export const ALLOWED_DIAGRAM_KINDS = [ + "flowchart", + "sequenceDiagram", + "stateDiagram-v2", + "classDiagram", + "erDiagram" +] as const; + +export type AllowedDiagramKind = (typeof ALLOWED_DIAGRAM_KINDS)[number]; + +export const MAX_DIAGRAMS_PER_DESCRIPTION = 3; + +export const MAX_DIAGRAM_SOURCE_BYTES = 20 * 1024; + +export type DiagramRuleReason = "caption" | "size" | "kind"; + +const MERMAID_FENCE_INFO_PATTERN = /^mermaid[ \t]*$/; + +const CAPTION_PATTERN = /^\s*\*\*(Diagram — (?:(?!\*\*).)+)\*\*[ \t]*$/; + +const SIGNIFICANT_LINE_PATTERN = /^(?!\s*%%)[^\s]/; + +export function isMermaidFenceInfo(info: string): boolean { + return MERMAID_FENCE_INFO_PATTERN.test(info); +} + +export function isAllowedDiagramKind(kind: string): kind is AllowedDiagramKind { + return (ALLOWED_DIAGRAM_KINDS as readonly string[]).includes(kind); +} + +export function matchDiagramCaption(line: string): string | undefined { + const match = line.match(CAPTION_PATTERN); + return match ? match[1].trim() : undefined; +} + +export function diagramKindOf(source: string): string | undefined { + for (const line of source.split("\n")) { + if (!SIGNIFICANT_LINE_PATTERN.test(line)) { + continue; + } + return line.trim().split(/\s+/)[0]; + } + return undefined; +} + +export function diagramSourceByteLength(source: string): number { + return Buffer.byteLength(source, "utf8"); +} + +export interface DiagramFenceInput { + caption?: string; + source: string; +} + +export type DiagramFenceEvaluation = + | { allowed: true; kind: AllowedDiagramKind } + | { allowed: false; reason: DiagramRuleReason; kind?: string }; + +export function evaluateDiagramFence( + fence: DiagramFenceInput +): DiagramFenceEvaluation { + if (!fence.caption) { + return { allowed: false, reason: "caption" }; + } + + if (diagramSourceByteLength(fence.source) > MAX_DIAGRAM_SOURCE_BYTES) { + return { allowed: false, reason: "size" }; + } + + const kind = diagramKindOf(fence.source); + if (!kind || !isAllowedDiagramKind(kind)) { + return { allowed: false, reason: "kind", kind }; + } + + return { allowed: true, kind }; +} diff --git a/packages/description-renderer/src/sanitize.ts b/packages/description-renderer/src/sanitize.ts new file mode 100644 index 00000000..fb27702f --- /dev/null +++ b/packages/description-renderer/src/sanitize.ts @@ -0,0 +1,55 @@ +import { parseSvgDocument, serializeSvgDocument } from "./dom"; + +const BLOCKED_ELEMENTS = new Set([ + "script", + "foreignObject", + "iframe", + "object", + "embed", + "use", + "link", + "meta", + "base", + "image", + "img" +]); + +const BLOCKED_ATTRIBUTE_PATTERN = /^on/i; +const EXTERNAL_REFERENCE_PATTERN = /^(?!#)/; + +function sanitizeElement(element: Element): void { + for (const child of Array.from(element.children)) { + if (child.localName === "a") { + sanitizeElement(child); + for (const grandChild of Array.from(child.children)) { + element.insertBefore(grandChild, child); + } + element.removeChild(child); + } else if (BLOCKED_ELEMENTS.has(child.localName)) { + element.removeChild(child); + } else { + sanitizeElement(child); + } + } + + for (const attribute of Array.from(element.attributes)) { + const name = attribute.name; + if (BLOCKED_ATTRIBUTE_PATTERN.test(name)) { + element.removeAttribute(name); + continue; + } + + if ( + (name === "href" || name === "xlink:href") && + EXTERNAL_REFERENCE_PATTERN.test(attribute.value.trim()) + ) { + element.removeAttribute(name); + } + } +} + +export function sanitizeSvg(svg: string): string { + const document = parseSvgDocument(svg); + sanitizeElement(document.documentElement); + return serializeSvgDocument(document); +} diff --git a/packages/description-renderer/test/cache.test.ts b/packages/description-renderer/test/cache.test.ts new file mode 100644 index 00000000..3f941d25 --- /dev/null +++ b/packages/description-renderer/test/cache.test.ts @@ -0,0 +1,41 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { + clearMermaidRenderCache, + invalidateMermaidRenderCache, + renderMermaidDiagram +} from "../src/render"; +import { FLOWCHART_SOURCE } from "./helpers/fixtures"; + +test.beforeEach(() => { + clearMermaidRenderCache(); +}); + +test("renderMermaidDiagram reuses the exact source and effective theme", async () => { + const first = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + const second = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + + assert.strictEqual(second, first); +}); + +test("renderMermaidDiagram keeps exact source and theme variants separate", async () => { + const light = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + const dark = await renderMermaidDiagram(FLOWCHART_SOURCE, "dark"); + const sourceVariant = await renderMermaidDiagram( + `${FLOWCHART_SOURCE}\n`, + "light" + ); + + assert.notStrictEqual(dark, light); + assert.notStrictEqual(sourceVariant, light); + assert.notEqual(dark.svg, light.svg); +}); + +test("theme invalidation drops cached rendered diagrams", async () => { + const first = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + + invalidateMermaidRenderCache(); + + const second = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + assert.notStrictEqual(second, first); +}); diff --git a/packages/description-renderer/test/helpers/fixtures.ts b/packages/description-renderer/test/helpers/fixtures.ts new file mode 100644 index 00000000..83a4ff3e --- /dev/null +++ b/packages/description-renderer/test/helpers/fixtures.ts @@ -0,0 +1,213 @@ +export const PNG_SIGNATURE = Buffer.from([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a +]); + +export function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function extractPngDataUri(markdown: string, altText: string): Buffer { + const pattern = new RegExp( + `!\\[${escapeRegExp(altText)}\\]\\(data:image\\/png;base64,([A-Za-z0-9+/=]+)\\)` + ); + const match = markdown.match(pattern); + if (!match) { + throw new Error( + `Expected a PNG data URI image with alt text "${altText}" in:\n${markdown.slice(0, 400)}` + ); + } + return Buffer.from(match[1], "base64"); +} + +export function assertValidPng(png: Buffer): void { + if (png.length < 200) { + throw new Error(`Expected a non-trivial PNG, got ${png.length} bytes`); + } + if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) { + throw new Error("Expected a PNG signature at the start of the image"); + } + const width = png.readUInt32BE(16); + const height = png.readUInt32BE(20); + if (width < 20 || height < 20) { + throw new Error(`Expected a visible diagram size, got ${width}x${height}`); + } + return void [width, height]; +} + +export function captionedDiagram(caption: string, source: string): string { + return ["**" + caption + "**", "", "```mermaid", source, "```"].join("\n"); +} + +export function findImageLine(markdown: string, altText: string): string { + const prefix = escapeRegExp(altText).replace( + /(\\\[|\\\])/g, + "\\\\?$1" + ); + const pattern = new RegExp(`^!\\[${prefix}`); + const line = markdown.split("\n").find(line => pattern.test(line)); + if (!line) { + throw new Error( + `Expected an image line with alt text "${altText}" in:\n${markdown.slice(0, 400)}` + ); + } + return line; +} + +export const FLOWCHART_SOURCE = [ + "flowchart TD", + " Client --> Gateway --> Service", + " Service --> Database" +].join("\n"); + +export const CAPTIONED_FLOWCHART_DESCRIPTION = [ + "The request flows through three layers:", + "", + "**Diagram — Request lifecycle**", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```", + "", + "That is the whole story." +].join("\n"); + +export const ALLOWED_KIND_SOURCES: Record = { + flowchart: FLOWCHART_SOURCE, + sequenceDiagram: [ + "sequenceDiagram", + " autonumber", + " participant User", + " participant Player as Tour Player", + " User->>Player: Start tour", + " Player-->>User: Step ready", + " note right of Player: Diagrams render per fence" + ].join("\n"), + "stateDiagram-v2": [ + "stateDiagram-v2", + " [*] --> Idle", + " Idle --> Loading: start tour", + " Loading --> Playing: step found", + " note right of Loading : waiting for the file", + " Playing --> Done: finish", + " Done --> [*]" + ].join("\n"), + classDiagram: [ + "classDiagram", + " class CodeTour {", + " +string title", + " +Step[] steps", + " +start() void", + " -persist()", + " }", + " class Step {", + " +string description", + " }", + " CodeTour \"1\" *-- \"many\" Step : contains" + ].join("\n"), + erDiagram: [ + "erDiagram", + " TOUR ||--o{ STEP : contains", + " STEP ||--|| FILE : anchors", + " TOUR {", + " string title \"the tour title\"", + " }", + " STEP {", + " string description", + " int line", + " }" + ].join("\n") +}; + +export const ALLOWED_KIND_CAPTIONS: Record = { + flowchart: "Diagram — Request lifecycle", + sequenceDiagram: "Diagram — Tour playback sequence", + "stateDiagram-v2": "Diagram — Tour playback states", + classDiagram: "Diagram — Tour data model", + erDiagram: "Diagram — Tour entity relationships" +}; + +export const UNSUPPORTED_KIND_SOURCES: Record = { + pie: ['pie title Pets adopted', ' "Dogs" : 386', ' "Cats" : 85'].join( + "\n" + ), + gantt: [ + "gantt", + " title Release plan", + " dateFormat YYYY-MM-DD", + " section Build", + " Package :p1, 2024-01-01, 3d" + ].join("\n"), + gitGraph: ["gitGraph", " commit", " branch feature", " commit"].join( + "\n" + ), + mindmap: ["mindmap", " root((tour))", " steps"].join("\n"), + journey: [ + "journey", + " title Taking a tour", + " section Start", + " Me: 5: Me" + ].join("\n"), + "unknown kind": ["squarewave TD", " A --> B"].join("\n") +}; + +export function flowchartOfExactByteLength(bytes: number): string { + const base = "flowchart TD\n A --> B\n%%"; + const padding = bytes - Buffer.byteLength(base, "utf8"); + if (padding < 0) { + throw new Error(`Cannot build a flowchart of only ${bytes} bytes`); + } + return base + "x".repeat(padding); +} + +export const HOSTILE_LABEL_SOURCES: Record = { + flowchart: [ + "flowchart TD", + ' A[""] --> B[""]' + ].join("\n"), + sequenceDiagram: [ + "sequenceDiagram", + ' participant U as ""', + ' U->>U: ""' + ].join("\n"), + "stateDiagram-v2": [ + "stateDiagram-v2", + " [*] --> Idle", + ' Idle --> Playing: ""', + ' state "" as S', + " Playing --> S" + ].join("\n"), + classDiagram: [ + "classDiagram", + ' class Foo[""] {', + " +string bar", + " }", + ' note for Foo "a note with "' + ].join("\n"), + erDiagram: [ + "erDiagram", + " TOUR {", + ' string title "comment "', + " }" + ].join("\n") +}; + +export const HOSTILE_INTERACTION_SOURCE = [ + "flowchart TD", + " A --> B", + ' click A "https://example.com" "tooltip"', + ' click B href "https://example.com/linked" "tooltip"', + ' click A call alert(1) "tooltip"', + " linkStyle 0 stroke:#f66,stroke-width:2px" +].join("\n"); + +export const HOSTILE_MARKDOWN_LABEL_SOURCE = [ + "flowchart TD", + ' A["See [docs](https://evil.example) and [run](command:codetour.nextTourStep)"] --> B' +].join("\n"); diff --git a/packages/description-renderer/test/mermaid-save.test.ts b/packages/description-renderer/test/mermaid-save.test.ts new file mode 100644 index 00000000..3aae9e35 --- /dev/null +++ b/packages/description-renderer/test/mermaid-save.test.ts @@ -0,0 +1,56 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { renderDescription } from "../src/description"; +import { clearMermaidRenderCache, renderMermaidDiagram } from "../src/render"; +import { + assertValidPng, + captionedDiagram, + extractPngDataUri, + FLOWCHART_SOURCE +} from "./helpers/fixtures"; + +// Regression for the "Save Step" flow not re-rendering Mermaid. After the +// source is edited and saved, the preview must show a PNG for the new source, +// hide the fence, and no longer match the previous diagram. +const PRIOR_SOURCE = FLOWCHART_SOURCE; // Client --> Gateway --> Service +const SAVED_SOURCE = [ + "flowchart TD", + " Author --> Reviewer --> Merge" +].join("\n"); +const CAPTION = "Diagram — Saved step"; + +test.beforeEach(() => { + clearMermaidRenderCache(); +}); + +test("the preview after a save renders the new source as a PNG without a fence", async () => { + // Before the edit, the step previewed the prior diagram. + const priorPreview = await renderDescription( + captionedDiagram(CAPTION, PRIOR_SOURCE), + "light" + ); + assertValidPng(extractPngDataUri(priorPreview, CAPTION)); + + // After saving the edited source, the preview is regenerated from it. + const savedPreview = await renderDescription( + captionedDiagram(CAPTION, SAVED_SOURCE), + "light" + ); + + assertValidPng(extractPngDataUri(savedPreview, CAPTION)); + assert.ok(!savedPreview.includes("```mermaid")); + assert.ok(!savedPreview.includes("flowchart TD")); + assert.ok(!savedPreview.includes("Author --> Reviewer")); + assert.ok(!savedPreview.includes("Client --> Gateway")); +}); + +test("the regenerated image matches the saved source, not the prior one", async () => { + const prior = await renderMermaidDiagram(PRIOR_SOURCE, "light"); + const saved = await renderMermaidDiagram(SAVED_SOURCE, "light"); + + assert.ok(prior.svg.includes("Client")); + assert.ok(!prior.svg.includes("Author")); + assert.ok(saved.svg.includes("Author")); + assert.ok(!saved.svg.includes("Client")); + assert.ok(Buffer.compare(saved.png, prior.png) !== 0); +}); diff --git a/packages/description-renderer/test/offline.test.ts b/packages/description-renderer/test/offline.test.ts new file mode 100644 index 00000000..0f9b103b --- /dev/null +++ b/packages/description-renderer/test/offline.test.ts @@ -0,0 +1,141 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { renderDescription } from "../src/description"; +import { + ALLOWED_KIND_CAPTIONS, + ALLOWED_KIND_SOURCES, + CAPTIONED_FLOWCHART_DESCRIPTION, + assertValidPng, + captionedDiagram, + extractPngDataUri +} from "./helpers/fixtures"; + +function blockNetwork(): { attempts: () => number; restore: () => void } { + const originalFetch = globalThis.fetch; + const originalConnect = net.Socket.prototype.connect; + let attempted = 0; + + const countAttempt = () => { + attempted++; + }; + + const failingConnect = (function( + this: net.Socket, + ...args: unknown[] + ): net.Socket { + countAttempt(); + throw new Error("Network access was attempted during rendering"); + } as unknown as typeof net.Socket.prototype.connect); + + globalThis.fetch = (async () => { + countAttempt(); + throw new Error("Network access was attempted during rendering"); + }) as typeof fetch; + + Object.defineProperty(net.Socket.prototype, "connect", { + value: failingConnect, + configurable: true, + writable: true, + enumerable: false + }); + + return { + attempts: () => attempted, + restore() { + globalThis.fetch = originalFetch; + Object.defineProperty(net.Socket.prototype, "connect", { + value: originalConnect, + configurable: true, + writable: true, + enumerable: false + }); + } + }; +} + +function listFilesRecursive(directory: string): string[] { + const entries: string[] = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + entries.push(...listFilesRecursive(entryPath)); + } else { + entries.push(entryPath); + } + } + return entries; +} + +test("renderDescription renders a flowchart with network access blocked", async () => { + const network = blockNetwork(); + + try { + const content = await renderDescription( + CAPTIONED_FLOWCHART_DESCRIPTION, + "light" + ); + assertValidPng(extractPngDataUri(content, "Diagram — Request lifecycle")); + assert.equal(network.attempts(), 0); + } finally { + network.restore(); + } +}); + +test("renderDescription renders the other allowed kinds with network access blocked", async () => { + const network = blockNetwork(); + + try { + for (const kind of [ + "sequenceDiagram", + "stateDiagram-v2", + "classDiagram", + "erDiagram" + ]) { + const caption = ALLOWED_KIND_CAPTIONS[kind]; + const content = await renderDescription( + captionedDiagram(caption, ALLOWED_KIND_SOURCES[kind]), + "light" + ); + assertValidPng(extractPngDataUri(content, caption)); + } + assert.equal(network.attempts(), 0); + } finally { + network.restore(); + } +}); + +test("renderDescription creates no generated SVG or PNG file", async () => { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "codetour-renderer-")); + const previousCwd = process.cwd(); + + try { + process.chdir(sandbox); + const description = [ + CAPTIONED_FLOWCHART_DESCRIPTION, + "", + captionedDiagram( + ALLOWED_KIND_CAPTIONS["stateDiagram-v2"], + ALLOWED_KIND_SOURCES["stateDiagram-v2"] + ) + ].join("\n"); + const content = await renderDescription(description, "dark"); + assertValidPng(extractPngDataUri(content, "Diagram — Request lifecycle")); + assertValidPng( + extractPngDataUri(content, ALLOWED_KIND_CAPTIONS["stateDiagram-v2"]) + ); + + const files = listFilesRecursive(sandbox); + assert.deepEqual( + files, + [], + "Expected the renderer to leave the workspace without generated assets" + ); + } finally { + process.chdir(previousCwd); + fs.rmSync(sandbox, { recursive: true, force: true }); + } +}); diff --git a/packages/description-renderer/test/rules.test.ts b/packages/description-renderer/test/rules.test.ts new file mode 100644 index 00000000..bf66b6b6 --- /dev/null +++ b/packages/description-renderer/test/rules.test.ts @@ -0,0 +1,160 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { + ALLOWED_DIAGRAM_KINDS, + MAX_DIAGRAMS_PER_DESCRIPTION, + MAX_DIAGRAM_SOURCE_BYTES, + diagramKindOf, + diagramSourceByteLength, + evaluateDiagramFence, + isAllowedDiagramKind, + isMermaidFenceInfo, + matchDiagramCaption +} from "../src/rules"; + +test("the allowlist accepts exactly the five first-version diagram kinds", () => { + assert.deepEqual(ALLOWED_DIAGRAM_KINDS, [ + "flowchart", + "sequenceDiagram", + "stateDiagram-v2", + "classDiagram", + "erDiagram" + ]); + assert.equal(MAX_DIAGRAMS_PER_DESCRIPTION, 3); + assert.equal(MAX_DIAGRAM_SOURCE_BYTES, 20 * 1024); +}); + +test("diagramKindOf reads the kind from the first significant line", () => { + assert.equal(diagramKindOf("flowchart TD\n A --> B"), "flowchart"); + assert.equal(diagramKindOf("sequenceDiagram\n A->>B: hi"), "sequenceDiagram"); + assert.equal( + diagramKindOf("stateDiagram-v2\n [*] --> Idle"), + "stateDiagram-v2" + ); + assert.equal(diagramKindOf("classDiagram\n class A"), "classDiagram"); + assert.equal(diagramKindOf("erDiagram\n A ||--o{ B : c"), "erDiagram"); +}); + +test("diagramKindOf skips blank lines, comments and init directives", () => { + assert.equal( + diagramKindOf("\n\n%% a comment\n%%{init: {'theme':'base'}}%%\nflowchart TD\n A --> B"), + "flowchart" + ); +}); + +test("diagramKindOf returns undefined for empty or comment-only sources", () => { + assert.equal(diagramKindOf(""), undefined); + assert.equal(diagramKindOf("%% only a comment"), undefined); + assert.equal(diagramKindOf(" \n \n"), undefined); +}); + +test("diagramKindOf takes the first token, not the whole line", () => { + assert.equal(diagramKindOf("pie title Pets"), "pie"); + assert.equal(diagramKindOf("squarewave TD"), "squarewave"); +}); + +test("the allowlist is exact: aliases and other kinds are unsupported", () => { + assert.equal(isAllowedDiagramKind("graph"), false); + assert.equal(isAllowedDiagramKind("stateDiagram"), false); + for (const kind of ["pie", "gantt", "mindmap", "journey", "gitGraph"]) { + assert.equal(isAllowedDiagramKind(kind), false); + } + assert.equal(isAllowedDiagramKind("Flowchart"), false); +}); + +test("isMermaidFenceInfo accepts only the bare mermaid info string", () => { + assert.equal(isMermaidFenceInfo("mermaid"), true); + assert.equal(isMermaidFenceInfo("mermaid "), true); + assert.equal(isMermaidFenceInfo("mermaid\t"), true); + assert.equal(isMermaidFenceInfo("ts"), false); + assert.equal(isMermaidFenceInfo("mermaid x"), false); + assert.equal(isMermaidFenceInfo("Mermaid"), false); + assert.equal(isMermaidFenceInfo(""), false); +}); + +test("matchDiagramCaption accepts a visible Diagram caption line", () => { + assert.equal( + matchDiagramCaption("**Diagram — Request lifecycle**"), + "Diagram — Request lifecycle" + ); + assert.equal( + matchDiagramCaption(" **Diagram — Padded** "), + "Diagram — Padded" + ); + assert.equal( + matchDiagramCaption("**Diagram — Multi word caption with — dashes**"), + "Diagram — Multi word caption with — dashes" + ); +}); + +test("matchDiagramCaption rejects malformed caption lines", () => { + assert.equal(matchDiagramCaption("*Diagram — single stars*"), undefined); + assert.equal(matchDiagramCaption("**Diagram – hyphen**"), undefined); + assert.equal(matchDiagramCaption("**Diagram — **"), undefined); + assert.equal(matchDiagramCaption("**Diagram —**"), undefined); + assert.equal(matchDiagramCaption("Diagram — bare"), undefined); + assert.equal(matchDiagramCaption("**Diagram — a** and **b**"), undefined); + assert.equal(matchDiagramCaption("**Not a diagram — caption**"), undefined); + assert.equal(matchDiagramCaption("Some intro text"), undefined); + assert.equal(matchDiagramCaption("```"), undefined); + assert.equal(matchDiagramCaption(""), undefined); +}); + +test("diagramSourceByteLength counts UTF-8 bytes, not characters", () => { + assert.equal(diagramSourceByteLength("flowchart TD"), 12); + assert.equal(diagramSourceByteLength("ééé"), 6); + assert.equal(diagramSourceByteLength("—"), 3); +}); + +test("evaluateDiagramFence allows an in-bounds supported diagram", () => { + assert.deepEqual( + evaluateDiagramFence({ caption: "Diagram — x", source: "flowchart TD\n A --> B" }), + { allowed: true, kind: "flowchart" } + ); +}); + +test("evaluateDiagramFence rejects a missing caption before anything else", () => { + const evaluation = evaluateDiagramFence({ + source: "pie title way too large" + "x".repeat(MAX_DIAGRAM_SOURCE_BYTES) + }); + assert.deepEqual(evaluation, { allowed: false, reason: "caption" }); +}); + +test("evaluateDiagramFence rejects an oversized source before reading its kind", () => { + const evaluation = evaluateDiagramFence({ + caption: "Diagram — too big", + source: "flowchart TD\n%%" + "x".repeat(MAX_DIAGRAM_SOURCE_BYTES) + }); + assert.deepEqual(evaluation, { allowed: false, reason: "size" }); +}); + +test("evaluateDiagramFence rejects an unsupported kind", () => { + const evaluation = evaluateDiagramFence({ + caption: "Diagram — pie", + source: "pie title Pets\n \"Dogs\" : 386" + }); + assert.deepEqual(evaluation, { allowed: false, reason: "kind", kind: "pie" }); +}); + +test("evaluateDiagramFence reports the detected kind alongside the rejection", () => { + const evaluation = evaluateDiagramFence({ + caption: "Diagram — gantt", + source: "gantt\n dateFormat YYYY-MM-DD" + }); + assert.equal(evaluation.allowed, false); + if (!evaluation.allowed) { + assert.equal(evaluation.reason, "kind"); + assert.equal(evaluation.kind, "gantt"); + } +}); + +test("evaluateDiagramFence accepts a source of exactly 20 KB", () => { + const source = "flowchart TD\n%%" + "x".repeat( + MAX_DIAGRAM_SOURCE_BYTES - Buffer.byteLength("flowchart TD\n%%", "utf8") + ); + const evaluation = evaluateDiagramFence({ + caption: "Diagram — boundary", + source + }); + assert.deepEqual(evaluation, { allowed: true, kind: "flowchart" }); +}); diff --git a/packages/description-renderer/test/seam.test.ts b/packages/description-renderer/test/seam.test.ts new file mode 100644 index 00000000..cb271e18 --- /dev/null +++ b/packages/description-renderer/test/seam.test.ts @@ -0,0 +1,410 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { + makeRenderedImagesResponsive, + renderDescription +} from "../src/description"; +import { renderMermaidDiagram } from "../src/render"; +import { + ALLOWED_KIND_CAPTIONS, + ALLOWED_KIND_SOURCES, + CAPTIONED_FLOWCHART_DESCRIPTION, + FLOWCHART_SOURCE, + UNSUPPORTED_KIND_SOURCES, + captionedDiagram, + extractPngDataUri, + flowchartOfExactByteLength, + assertValidPng +} from "./helpers/fixtures"; + +const COUNT_NOTICE_MARKER = "at most three Mermaid diagrams"; +const CAPTION_NOTICE_MARKER = "**Diagram — …** caption"; +const SIZE_NOTICE_MARKER = "20 KB limit"; +const KIND_NOTICE_MARKER = "Unsupported Mermaid diagram kind"; +const RENDER_NOTICE_MARKER = "could not be rendered"; + +function assertNotice(content: string, marker: string): void { + assert.ok( + content.includes(marker), + `Expected the notice "${marker}" in:\n${content.slice(0, 400)}` + ); +} + +test("renderDescription renders a captioned flowchart as a PNG image", async () => { + const content = await renderDescription( + CAPTIONED_FLOWCHART_DESCRIPTION, + "light" + ); + + const png = extractPngDataUri(content, "Diagram — Request lifecycle"); + assertValidPng(png); +}); + +test("renderDescription keeps the caption visible and uses it as alt text", async () => { + const content = await renderDescription( + CAPTIONED_FLOWCHART_DESCRIPTION, + "light" + ); + + assert.ok(content.includes("**Diagram — Request lifecycle**")); + assert.ok(content.includes("![Diagram — Request lifecycle](data:image/png")); +}); + +test("renderDescription hides the Mermaid source during playback", async () => { + const content = await renderDescription( + CAPTIONED_FLOWCHART_DESCRIPTION, + "light" + ); + + assert.ok(!content.includes("```mermaid")); + assert.ok(!content.includes("flowchart TD")); + assert.ok(!content.includes("Client --> Gateway")); +}); + +test("renderDescription returns descriptions without Mermaid unchanged", async () => { + const description = [ + "A plain description with text.", + "", + "```ts", + "const answer = 42;", + "```", + "", + "[Next tour](command:codetour.startTourByTitle?%5B%22Next%22%5D)" + ].join("\n"); + + assert.equal(await renderDescription(description, "light"), description); + assert.equal(await renderDescription(description, "dark"), description); +}); + +test("makeRenderedImagesResponsive scales rendered PNGs to their container", () => { + const content = + "![Diagram — Reads \\[docs\\]](data:image/png;base64,iVBORw0KGgo=)"; + + assert.equal( + makeRenderedImagesResponsive(content), + 'Diagram — Reads [docs]' + ); +}); + +test("makeRenderedImagesResponsive escapes HTML-sensitive alt text", () => { + const content = + '![Diagram — A & "quoted"](data:image/png;base64,iVBORw0KGgo=)'; + + assert.equal( + makeRenderedImagesResponsive(content), + 'Diagram — A & "quoted"' + ); +}); + +test("makeRenderedImagesResponsive scales ordinary image links too", () => { + const content = "![Screenshot](https://example.com/screenshot.png)"; + + assert.equal( + makeRenderedImagesResponsive(content), + 'Screenshot' + ); +}); + +test("makeRenderedImagesResponsive preserves image titles", () => { + const content = '![Screenshot](https://example.com/screenshot.png "Full size")'; + + assert.equal( + makeRenderedImagesResponsive(content), + 'Screenshot' + ); +}); + +test("makeRenderedImagesResponsive does not rewrite PNGs inside code fences", () => { + const content = [ + "```markdown", + "![Screenshot](data:image/png;base64,iVBORw0KGgo=)", + "```" + ].join("\n"); + + assert.equal(makeRenderedImagesResponsive(content), content); +}); + +test("renderDescription renders every allowed diagram kind through the seam", async () => { + for (const [kind, source] of Object.entries(ALLOWED_KIND_SOURCES)) { + const caption = ALLOWED_KIND_CAPTIONS[kind]; + const content = await renderDescription(captionedDiagram(caption, source), "light"); + + const png = extractPngDataUri(content, caption); + assertValidPng(png); + assert.ok(content.includes(`**${caption}**`), kind); + assert.ok(!content.includes("```mermaid"), kind); + assert.ok(!content.includes(source), kind); + } +}); + +test("renderDescription renders a sequence diagram with message arrows", async () => { + const content = await renderDescription( + captionedDiagram( + ALLOWED_KIND_CAPTIONS.sequenceDiagram, + ALLOWED_KIND_SOURCES.sequenceDiagram + ), + "light" + ); + + assertValidPng( + extractPngDataUri(content, ALLOWED_KIND_CAPTIONS.sequenceDiagram) + ); +}); + +test("renderDescription renders a class diagram with members", async () => { + const content = await renderDescription( + captionedDiagram( + ALLOWED_KIND_CAPTIONS.classDiagram, + ALLOWED_KIND_SOURCES.classDiagram + ), + "light" + ); + + assertValidPng( + extractPngDataUri(content, ALLOWED_KIND_CAPTIONS.classDiagram) + ); +}); + +test("renderDescription fails an unsupported kind locally, keeping the caption as alternative text", async () => { + for (const [label, source] of Object.entries(UNSUPPORTED_KIND_SOURCES)) { + const caption = `Diagram — ${label} attempt`; + const content = await renderDescription(captionedDiagram(caption, source), "light"); + + assertNotice(content, KIND_NOTICE_MARKER); + assert.ok(content.includes(`**${caption}**`), label); + assert.ok(!content.includes("```mermaid"), label); + assert.ok(!content.includes(source), label); + } +}); + +test("renderDescription fails a Mermaid fence without a caption, without exposing its source", async () => { + const description = [ + "Some text that is not a caption.", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```" + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assertNotice(content, CAPTION_NOTICE_MARKER); + assert.ok(!content.includes("```mermaid")); + assert.ok(!content.includes(FLOWCHART_SOURCE)); +}); + +test("renderDescription fails a Mermaid fence whose caption is malformed", async () => { + for (const captionLine of [ + "*Diagram — single stars*", + "**Diagram – wrong dash**", + "**Diagram —**", + "**Diagram — a** and **b**" + ]) { + const description = [captionLine, "", "```mermaid", FLOWCHART_SOURCE, "```"].join("\n"); + + const content = await renderDescription(description, "light"); + + assertNotice(content, CAPTION_NOTICE_MARKER); + assert.ok(!content.includes("```mermaid"), captionLine); + assert.ok(!content.includes(FLOWCHART_SOURCE), captionLine); + } +}); + +test("renderDescription fails a Mermaid fence with markdown between the caption and the fence", async () => { + const description = [ + "**Diagram — Interrupted**", + "", + "Read this first:", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```" + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assertNotice(content, CAPTION_NOTICE_MARKER); + assert.ok(!content.includes("```mermaid")); + assert.ok(!content.includes(FLOWCHART_SOURCE)); +}); + +test("a caption can only introduce a single fence", async () => { + const description = [ + "**Diagram — First**", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```", + "```mermaid", + FLOWCHART_SOURCE, + "```" + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assertValidPng(extractPngDataUri(content, "Diagram — First")); + assertNotice(content, CAPTION_NOTICE_MARKER); + assert.ok(!content.includes("```mermaid")); +}); + +test("renderDescription fails a diagram source over 20 KB", async () => { + const source = flowchartOfExactByteLength(20 * 1024 + 1); + const content = await renderDescription( + captionedDiagram("Diagram — Too big", source), + "light" + ); + + assertNotice(content, SIZE_NOTICE_MARKER); + assert.ok(content.includes("**Diagram — Too big**")); + assert.ok(!content.includes("```mermaid")); + assert.ok(!content.includes(source.slice(0, 200))); +}); + +test("renderDescription renders a diagram source of exactly 20 KB", async () => { + const source = flowchartOfExactByteLength(20 * 1024); + const content = await renderDescription( + captionedDiagram("Diagram — Boundary", source), + "light" + ); + + assertValidPng(extractPngDataUri(content, "Diagram — Boundary")); +}); + +test("renderDescription renders at most three diagrams and fails the excess locally", async () => { + const description = [1, 2, 3, 4, 5] + .map(number => + captionedDiagram(`Diagram — Chart ${number}`, FLOWCHART_SOURCE) + ) + .join("\n\n"); + + const content = await renderDescription(description, "light"); + + for (const number of [1, 2, 3]) { + assertValidPng( + extractPngDataUri(content, `Diagram — Chart ${number}`) + ); + } + const excessNotices = content + .split("\n") + .filter(line => line.includes(COUNT_NOTICE_MARKER)); + assert.equal(excessNotices.length, 2); + assert.ok(content.includes("**Diagram — Chart 4**")); + assert.ok(content.includes("**Diagram — Chart 5**")); + assert.ok(!content.includes("```mermaid")); +}); + +test("the first three fences count toward the limit even when they fail other rules", async () => { + const description = [ + "Intro text", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```", + "", + captionedDiagram("Diagram — Unsupported", UNSUPPORTED_KIND_SOURCES.pie), + captionedDiagram("Diagram — Valid", FLOWCHART_SOURCE), + captionedDiagram("Diagram — Fourth", FLOWCHART_SOURCE) + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assertValidPng(extractPngDataUri(content, "Diagram — Valid")); + assertNotice(content, CAPTION_NOTICE_MARKER); + assertNotice(content, KIND_NOTICE_MARKER); + assertNotice(content, COUNT_NOTICE_MARKER); + assert.ok(!content.includes("```mermaid")); +}); + +test("renderDescription replaces an invalid diagram with a warning, not its source", async () => { + const description = [ + "**Diagram — Broken diagram**", + "", + "```mermaid", + "flowchart TD", + " this is not mermaid at all (((", + "```" + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assert.ok(!content.includes("```mermaid")); + assert.ok(!content.includes("this is not mermaid at all")); + assertNotice(content, RENDER_NOTICE_MARKER); + assert.ok(content.includes("**Diagram — Broken diagram**")); +}); + +test("renderDescription renders diagrams in one description independently", async () => { + const description = [ + "**Diagram — Valid diagram**", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```", + "", + "**Diagram — Broken diagram**", + "", + "```mermaid", + "flowchart TD", + " this is not mermaid at all (((", + "```" + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assertValidPng(extractPngDataUri(content, "Diagram — Valid diagram")); + assertNotice(content, RENDER_NOTICE_MARKER); + assert.ok(!content.includes("this is not mermaid at all")); +}); + +test("one rejected diagram never hides its valid siblings", async () => { + const description = [ + "Plain text above", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```", + "", + captionedDiagram("Diagram — Supported", FLOWCHART_SOURCE), + captionedDiagram("Diagram — Unsupported", UNSUPPORTED_KIND_SOURCES.gitGraph) + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assertValidPng(extractPngDataUri(content, "Diagram — Supported")); + assertNotice(content, CAPTION_NOTICE_MARKER); + assertNotice(content, KIND_NOTICE_MARKER); + assert.ok(!content.includes("```mermaid")); +}); + +test("renderDescription adapts the rendered diagram to the theme", async () => { + const light = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + const dark = await renderMermaidDiagram(FLOWCHART_SOURCE, "dark"); + + assertValidPng(light.png); + assertValidPng(dark.png); + assert.notEqual(light.svg, dark.svg); + assert.ok(Buffer.compare(light.png, dark.png) !== 0); +}); + +test("renderMermaidDiagram returns a sanitized SVG and an in-memory PNG", async () => { + const { svg, png } = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + + assert.ok(svg.startsWith("Client")); + assertValidPng(png); +}); + +test("renderMermaidDiagram refuses unsupported diagram kinds itself", async () => { + for (const source of [ + UNSUPPORTED_KIND_SOURCES.pie, + UNSUPPORTED_KIND_SOURCES.mindmap, + UNSUPPORTED_KIND_SOURCES["unknown kind"] + ]) { + await assert.rejects( + () => renderMermaidDiagram(source, "light"), + /Unsupported Mermaid diagram kind/u, + source.split("\n")[0] + ); + } +}); diff --git a/packages/description-renderer/test/security.test.ts b/packages/description-renderer/test/security.test.ts new file mode 100644 index 00000000..ff00381e --- /dev/null +++ b/packages/description-renderer/test/security.test.ts @@ -0,0 +1,211 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { renderDescription } from "../src/description"; +import { renderMermaidDiagram } from "../src/render"; +import { sanitizeSvg } from "../src/sanitize"; +import { + CAPTIONED_FLOWCHART_DESCRIPTION, + HOSTILE_INTERACTION_SOURCE, + HOSTILE_LABEL_SOURCES, + HOSTILE_MARKDOWN_LABEL_SOURCE, + assertValidPng, + captionedDiagram, + extractPngDataUri, + findImageLine +} from "./helpers/fixtures"; + +const IMAGE_LINE_PATTERN = /^!\[[^\n]*\]\(data:image\/png;base64,[A-Za-z0-9+/=]+\)$/; + +function assertStaticImageLine(content: string, caption: string): void { + const line = findImageLine(content, caption); + assert.match(line, IMAGE_LINE_PATTERN); + assert.ok(!line.includes("<")); +} + +test("strict security encodes hostile labels instead of embedding HTML", async () => { + const source = [ + "flowchart TD", + ' A[""] --> B[""]' + ].join("\n"); + + const { svg } = await renderMermaidDiagram(source, "light"); + + assert.ok(!svg.includes(" { + const source = [ + "flowchart TD", + " A --> B", + ' click A "https://example.com" "tooltip"' + ].join("\n"); + + const { svg } = await renderMermaidDiagram(source, "light"); + + assert.ok(!svg.includes(" { + const { svg } = await renderMermaidDiagram( + "flowchart TD\n A[Label] --> B", + "light" + ); + + assert.ok(!svg.includes("foreignObject")); + assert.ok(svg.includes(" { + for (const [kind, source] of Object.entries(HOSTILE_LABEL_SOURCES)) { + const caption = `Diagram — Hostile ${kind}`; + const content = await renderDescription(captionedDiagram(caption, source), "light"); + + assertStaticImageLine(content, caption); + assertValidPng(extractPngDataUri(content, caption)); + assert.ok(!content.includes(" { + const caption = "Diagram — Hostile parse failure"; + const content = await renderDescription( + captionedDiagram( + caption, + 'flowchart TD\n A[""] is totally broken (((' + ), + "light" + ); + + assert.ok(content.includes("could not be rendered")); + assert.ok(!content.includes(" { + const caption = "Diagram — Hostile interactions"; + const content = await renderDescription( + captionedDiagram(caption, HOSTILE_INTERACTION_SOURCE), + "light" + ); + + assertStaticImageLine(content, caption); + assertValidPng(extractPngDataUri(content, caption)); + assert.ok(!content.includes(" { + const caption = "Diagram — Hostile markdown label"; + const content = await renderDescription( + captionedDiagram(caption, HOSTILE_MARKDOWN_LABEL_SOURCE), + "light" + ); + + assertStaticImageLine(content, caption); + assertValidPng(extractPngDataUri(content, caption)); + assert.ok(!content.includes("](https://evil.example)")); + assert.ok(!content.includes("](command:")); +}); + +test("a caption containing brackets cannot break out of the image markdown", async () => { + const caption = "Diagram — Reads [docs](https://example.com) and [run](command:x)"; + const content = await renderDescription( + captionedDiagram(caption, "flowchart TD\n A --> B"), + "light" + ); + + assertStaticImageLine(content, caption); + const line = findImageLine(content, caption); + assert.equal(line.match(/!\[/g)!.length, 1); + assert.equal(line.match(/\]\(data:image\/png;base64,/g)!.length, 1); +}); + +test("the final comment content contains no diagram HTML or commands", async () => { + const content = await renderDescription( + CAPTIONED_FLOWCHART_DESCRIPTION, + "light" + ); + + const imageLine = findImageLine(content, "Diagram — Request lifecycle"); + assert.ok(!imageLine.includes("<")); + assert.ok(!imageLine.includes("command:")); + assertValidPng(extractPngDataUri(content, "Diagram — Request lifecycle")); +}); + +test("sanitizeSvg removes scripts, handlers, foreign content and anchors", () => { + const hostile = [ + '', + "", + '
html
', + '', + '', + '', + "
" + ].join(""); + + const sanitized = sanitizeSvg(hostile); + + assert.ok(!sanitized.includes(" { + const hostile = [ + '', + '', + '', + '', + '', + "" + ].join(""); + + const sanitized = sanitizeSvg(hostile); + + assert.ok(!sanitized.includes(" { + const ordinary = [ + '', + '', + 'Label', + "" + ].join(""); + + const sanitized = sanitizeSvg(ordinary); + + assert.ok(sanitized.includes('xlink:href="#label-path"')); + assert.ok(sanitized.includes(" { + const ordinary = [ + '', + 'Label', + '', + "" + ].join(""); + + const sanitized = sanitizeSvg(ordinary); + + assert.ok(sanitized.includes("Label")); + assert.ok(sanitized.includes('fill="#ECECFF"')); + assert.ok(sanitized.includes('transform="translate(1,1)"')); +}); diff --git a/packages/description-renderer/tsconfig.json b/packages/description-renderer/tsconfig.json new file mode 100644 index 00000000..11d5bb8b --- /dev/null +++ b/packages/description-renderer/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "node16", + "moduleResolution": "node16", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "declaration": true, + "types": ["node"] + }, + "include": ["src", "test"] +} From e1ec9fc12967e666bab8ed93b556722ca73a6bdd Mon Sep 17 00:00:00 2001 From: dhuyet Date: Thu, 3 Sep 2026 11:49:15 +0200 Subject: [PATCH 07/14] feat: render Mermaid diagrams across tour surfaces --- src/extension.ts | 5 + src/notebook/index.ts | 25 ++++- src/player/commands.ts | 19 +++- src/player/description.ts | 113 +++++++++++++++++++ src/player/index.ts | 180 ++++++++++++++---------------- src/player/insertCode.test.ts | 201 ++++++++++++++++++++++++++++++++++ src/player/insertCode.ts | 100 +++++++++++++++++ src/player/navigation.test.ts | 27 +++++ src/player/navigation.ts | 16 +++ src/player/tree/index.ts | 70 ++++++++++-- src/player/tree/nodes.ts | 1 - src/recorder/commands.ts | 44 +++++++- src/recorder/saveStep.test.ts | 17 +++ src/recorder/saveStep.ts | 28 +++++ src/store/index.ts | 17 --- src/store/provider.ts | 12 +- tsconfig.json | 10 +- tsconfig.test.json | 15 +++ 18 files changed, 751 insertions(+), 149 deletions(-) create mode 100644 src/player/description.ts create mode 100644 src/player/insertCode.test.ts create mode 100644 src/player/insertCode.ts create mode 100644 src/player/navigation.test.ts create mode 100644 src/player/navigation.ts create mode 100644 src/recorder/saveStep.test.ts create mode 100644 src/recorder/saveStep.ts create mode 100644 tsconfig.test.json diff --git a/src/extension.ts b/src/extension.ts index 8fea91d8..155e80aa 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import { initializeApi } from "./api"; import { initializeGitApi } from "./git"; import { registerLiveShareModule } from "./liveShare"; import { registerMcpProvider } from "./mcp"; +import { registerNotebookProvider } from "./notebook"; import { registerPlayerModule } from "./player"; import { registerRecorderModule } from "./recorder"; import { store } from "./store"; @@ -79,6 +80,10 @@ export async function activate(context: vscode.ExtensionContext) { if (vscode.env.uiKind === vscode.UIKind.Desktop) { registerMcpProvider(context); } + const notebookProvider = registerNotebookProvider(); + if (notebookProvider) { + context.subscriptions.push(notebookProvider); + } registerPlayerModule(context); registerRecorderModule(); registerLiveShareModule(); diff --git a/src/notebook/index.ts b/src/notebook/index.ts index 439c080f..2bdd103e 100644 --- a/src/notebook/index.ts +++ b/src/notebook/index.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { EXTENSION_NAME, SMALL_ICON_URL } from "../constants"; +import { renderPreviewDescription } from "../player/description"; import { CodeTour } from "../store"; import { getStepFileUri, getWorkspaceUri } from "../utils"; @@ -35,20 +36,31 @@ class CodeTourNotebookProvider implements vscode.NotebookSerializer { steps.push({ contents, language: document.languageId, - description: item.description, + description: await renderPreviewDescription(item.description, undefined, { + tour, + workspaceRoot + }), uri }); } let cells: vscode.NotebookCellData[] = []; + const titleDescription = + tour.description === undefined + ? "" + : await renderPreviewDescription(tour.description, undefined, { + tour, + workspaceRoot + }); + // Title cell cells.push( new vscode.NotebookCellData( 1, `## ![Icon](${SMALL_ICON_URL})   CodeTour (${tour.title}) - ${ steps.length - } steps\n\n${tour.description === undefined ? "" : tour.description}`, + } steps\n\n${titleDescription}`, "markdown" ) ); @@ -67,6 +79,7 @@ class CodeTourNotebookProvider implements vscode.NotebookSerializer { ) ]) ]; + cells.push(cell); }); return new vscode.NotebookData(cells); @@ -80,8 +93,12 @@ class CodeTourNotebookProvider implements vscode.NotebookSerializer { } } -export function registerNotebookProvider() { - vscode.notebook.registerNotebookSerializer( +export function registerNotebookProvider(): vscode.Disposable | undefined { + if (typeof vscode.notebook === "undefined") { + return undefined; + } + + return vscode.notebook.registerNotebookSerializer( EXTENSION_NAME, new CodeTourNotebookProvider() ); diff --git a/src/player/commands.ts b/src/player/commands.ts index 6bf7bbb5..259f37da 100644 --- a/src/player/commands.ts +++ b/src/player/commands.ts @@ -21,8 +21,20 @@ import { CodeTourNode } from "./tree/nodes"; let terminal: vscode.Terminal | null; export function registerPlayerCommands() { - // This is a "private" command that's used exclusively - // by the hover description for tour markers. + vscode.commands.registerCommand( + `${EXTENSION_NAME}._getActiveCommentBody`, + () => { + const comment = store.activeTour?.thread?.comments[0]; + if (!comment) { + return undefined; + } + + return comment.body instanceof vscode.MarkdownString + ? comment.body.value + : comment.body; + } + ); + vscode.commands.registerCommand( `${EXTENSION_NAME}._startTourById`, async (id: string, stepNumber: number) => { @@ -33,7 +45,6 @@ export function registerPlayerCommands() { } ); - // Purpose: Command link vscode.commands.registerCommand( `${EXTENSION_NAME}.startTourByTitle`, async (title: string, stepNumber?: number) => { @@ -68,7 +79,6 @@ export function registerPlayerCommands() { } ); - // Purpose: Command link vscode.commands.registerCommand( `${EXTENSION_NAME}.navigateToStep`, async (stepNumber: number) => { @@ -83,7 +93,6 @@ export function registerPlayerCommands() { } ); - // Purpose: Command link and the ">>" syntax vscode.commands.registerCommand( `${EXTENSION_NAME}.sendTextToTerminal`, async (text: string) => { diff --git a/src/player/description.ts b/src/player/description.ts new file mode 100644 index 00000000..3949e6a6 --- /dev/null +++ b/src/player/description.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ColorThemeKind, Uri, window, workspace } from "vscode"; +import { + DescriptionTheme, + makeRenderedImagesResponsive, + renderDescription +} from "codetour-description-renderer"; +import { CodeTour, store } from "../store"; +import { getTourTitle } from "../utils"; +import { appendInsertCodeLinks } from "./insertCode"; + +const SHELL_SCRIPT_PATTERN = /^>>\s+(?
Bad link', + "Tour description", + "test-nonce" + ); + + assert.doesNotMatch(html, /alert\("unsafe"\)/); + assert.doesNotMatch(html, /href="javascript:/); + assert.match(html, />Bad link<\/a>/); +}); diff --git a/src/player/descriptionWebview.ts b/src/player/descriptionWebview.ts new file mode 100644 index 00000000..a23bce03 --- /dev/null +++ b/src/player/descriptionWebview.ts @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomBytes } from "crypto"; +import * as vscode from "vscode"; +import { store } from "../store"; +import { getStepLabel } from "../utils"; +import { renderPreviewDescription } from "./description"; +import { createDescriptionWebviewHtml } from "./markdown"; + +const VIEW_TYPE = "codetour.description"; +const ALLOWED_COMMANDS = /^(codetour\.|vscode\.open$)/; + +let panel: vscode.WebviewPanel | undefined; +let linkWorkspaceRoot: vscode.Uri | undefined; + +function reviveCommandArgument(value: unknown): unknown { + if ( + value && + typeof value === "object" && + "scheme" in value && + "path" in value + ) { + const components = value as { + scheme: string; + authority?: string; + path?: string; + query?: string; + fragment?: string; + }; + return vscode.Uri.from({ + scheme: components.scheme, + authority: components.authority, + path: components.path, + query: components.query, + fragment: components.fragment + }); + } + + return value; +} + +async function openLink( + href: string, + workspaceRoot?: vscode.Uri +): Promise { + const uri = vscode.Uri.parse(href, true); + if (uri.scheme === "command" && ALLOWED_COMMANDS.test(uri.path)) { + const parsedArgs: unknown = uri.query + ? JSON.parse(decodeURIComponent(uri.query)) + : []; + const args = (Array.isArray(parsedArgs) ? parsedArgs : [parsedArgs]).map( + reviveCommandArgument + ); + await vscode.commands.executeCommand(uri.path, ...args); + } else if (["http", "https", "mailto"].includes(uri.scheme)) { + await vscode.env.openExternal(uri); + } else if (!uri.scheme && workspaceRoot) { + await vscode.commands.executeCommand( + "vscode.open", + vscode.Uri.joinPath(workspaceRoot, uri.path) + ); + } +} + +export async function showStepDescription(): Promise { + const activeTour = store.activeTour; + if (!activeTour) { + return; + } + + const { tour, step, workspaceRoot } = activeTour; + const currentStep = tour.steps[step]; + if (!currentStep?.description) { + return; + } + + const title = `Description — ${getStepLabel(tour, step)}`; + const content = await renderPreviewDescription(currentStep.description, undefined, { + tour, + tours: activeTour.tours, + workspaceRoot + }); + + if (!panel) { + panel = vscode.window.createWebviewPanel( + VIEW_TYPE, + title, + vscode.ViewColumn.Beside, + { enableScripts: true } + ); + panel.onDidDispose(() => { + panel = undefined; + linkWorkspaceRoot = undefined; + }); + panel.webview.onDidReceiveMessage(async message => { + if (message?.type === "openLink" && typeof message.href === "string") { + try { + await openLink(message.href, linkWorkspaceRoot); + } catch { + vscode.window.showErrorMessage("Unable to open this description link."); + } + } + }); + } else { + panel.reveal(vscode.ViewColumn.Beside); + panel.title = title; + } + + linkWorkspaceRoot = workspaceRoot; + panel.webview.html = createDescriptionWebviewHtml( + content, + title, + randomBytes(16).toString("base64") + ); +} diff --git a/src/player/markdown.ts b/src/player/markdown.ts new file mode 100644 index 00000000..d3a61818 --- /dev/null +++ b/src/player/markdown.ts @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import MarkdownIt = require("markdown-it"); +import sanitizeHtml = require("sanitize-html"); + +const markdown = new MarkdownIt({ + html: true, + linkify: true +}); + +/** + * Converts CodeTour Markdown to HTML for extension-owned surfaces. + * + * Raw HTML is accepted by the parser because the shared description pipeline + * emits responsive image tags. The sanitizer is therefore the security + * boundary and deliberately keeps only presentation markup and links. + */ +export function renderMarkdownToHtml(content: string): string { + return sanitizeHtml(markdown.render(content), { + allowedTags: [ + ...sanitizeHtml.defaults.allowedTags, + "img" + ], + allowedAttributes: { + "*": ["id"], + a: ["href", "title"], + code: ["class"], + img: ["alt", "src", "title", "width"] + }, + allowedSchemes: ["http", "https", "mailto", "command"], + allowedSchemesByTag: { + img: ["http", "https", "data"] + } + }); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function createDescriptionWebviewHtml( + content: string, + title: string, + nonce: string +): string { + const renderedContent = renderMarkdownToHtml(content); + const safeTitle = escapeHtml(title); + + return ` + + + + + + ${safeTitle} + + + + ${renderedContent} + + +`; +} diff --git a/tsconfig.test.json b/tsconfig.test.json index fe0ea7a4..f810d6cc 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -7,8 +7,11 @@ "include": [ "src/player/insertCode.ts", "src/player/insertCode.test.ts", + "src/player/commentMenu.test.ts", "src/player/navigation.ts", "src/player/navigation.test.ts", + "src/player/descriptionWebview.test.ts", + "src/player/markdown.ts", "src/recorder/saveStep.ts", "src/recorder/saveStep.test.ts" ] From 591effd637749d52e1835e623afe1480d7380c36 Mon Sep 17 00:00:00 2001 From: dhuyet Date: Fri, 4 Sep 2026 12:49:30 +0200 Subject: [PATCH 13/14] fix: restore readable Markdown guidance Port the relevant guidance from 36f6d134fa664635ef75599430bd12a7890274df to the two-tool MCP API and clarify that blank lines, not Markdown soft breaks, create visible paragraphs. --- packages/mcp-server/src/mermaid-validation.ts | 5 ++- packages/mcp-server/src/server.ts | 40 +++++++++++++++++-- .../test/integration/security.test.ts | 29 +++++++++++++- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/mcp-server/src/mermaid-validation.ts b/packages/mcp-server/src/mermaid-validation.ts index 7ed4ef5b..93d840bb 100644 --- a/packages/mcp-server/src/mermaid-validation.ts +++ b/packages/mcp-server/src/mermaid-validation.ts @@ -11,7 +11,10 @@ import { import { Issue } from "./types"; const MERMAID_TOOL_GUIDANCE = - `Use Mermaid sparingly: add a diagram only when it materially clarifies a relationship, flow, state, sequence, class, or entity. ` + + `Use Mermaid when it materially clarifies a relationship, flow, state, sequence, class, or entity. ` + + `For architecture, workflow, lifecycle, or multi-module Tours, include at least one Mermaid diagram ` + + `when it can summarize relationships spanning several steps. Omit diagrams only when the Tour has ` + + `no meaningful relationship, flow, state transition, or sequence to visualize. ` + "If you use one, put the nearest non-blank line before a bare ```mermaid fence in the form " + "**Diagram — …**; keep that caption visible and descriptive. " + `Only these Mermaid kinds are allowed: ${ALLOWED_DIAGRAM_KINDS.join(", ")}. ` + diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index d5a2d83b..60afa49e 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -43,6 +43,16 @@ const SERVER_VERSION = packageJson.version; const PROJECT_TOUR_PATH = ".tours/project.tour"; const CHANGES_TOUR_PATH = ".tours/changes.tour"; +const MARKDOWN_WRITING_GUIDANCE = + "Write the Tour-level description and every step description as readable Markdown. " + + "Split distinct ideas into short paragraphs separated by blank lines (`\\n\\n`), because " + + "a single newline (`\\n`) is a Markdown soft break and may render as a space. Use a short " + + "heading when a description covers multiple topics, and use bullet or numbered lists " + + "for collections, alternatives, or sequences. Use **bold** sparingly for important " + + "concepts and backticks for code identifiers. Avoid dense monolithic paragraphs, and " + + "keep each explanation concise and tied to the current Tour Anchor. Do not add " + + "structure mechanically: a short, single-purpose description may remain one paragraph. "; + const PROJECT_TOUR_DESCRIPTION = "Creates a CodeTour Project Tour that explains a codebase as a whole, persisted at " + ".tours/project.tour (replacing any previously generated tour of the same kind). " + @@ -61,6 +71,7 @@ const PROJECT_TOUR_DESCRIPTION = "context. Every anchor is " + "validated against the real workspace state, and all validation errors are reported in a single " + "response. On failure, the previous tour file is preserved. " + + MARKDOWN_WRITING_GUIDANCE + MERMAID_TOOL_GUIDANCE; const CHANGES_TOUR_DESCRIPTION = @@ -78,6 +89,7 @@ const CHANGES_TOUR_DESCRIPTION = "Uncommitted changes are excluded by default and reported as a warning; pass includeUncommittedChanges to " + "include them explicitly. The description is automatically enriched with the base, merge-base and " + "head. On failure, the previous tour file is preserved. " + + MARKDOWN_WRITING_GUIDANCE + MERMAID_TOOL_GUIDANCE; const warningSchema = z.object({ @@ -101,16 +113,36 @@ const toolResultSchema = z.object({ const projectToolInputSchema = z .object({ title: z.unknown().optional(), - description: z.unknown().optional(), - steps: z.unknown().optional(), + description: z + .unknown() + .optional() + .describe( + "Optional readable Markdown overview; separate distinct ideas with blank lines between paragraphs." + ), + steps: z + .unknown() + .optional() + .describe( + "Non-empty array whose descriptions use concise Markdown with blank lines between paragraphs." + ), }) .passthrough(); const changesToolInputSchema = z .object({ title: z.unknown().optional(), - description: z.unknown().optional(), - steps: z.unknown().optional(), + description: z + .unknown() + .optional() + .describe( + "Optional readable Markdown overview; separate distinct ideas with blank lines between paragraphs." + ), + steps: z + .unknown() + .optional() + .describe( + "Non-empty array whose descriptions use concise Markdown with blank lines between paragraphs." + ), baseRef: z.unknown().optional(), headRef: z.unknown().optional(), includeUncommittedChanges: z.unknown().optional(), diff --git a/packages/mcp-server/test/integration/security.test.ts b/packages/mcp-server/test/integration/security.test.ts index 903bde94..13081fa1 100644 --- a/packages/mcp-server/test/integration/security.test.ts +++ b/packages/mcp-server/test/integration/security.test.ts @@ -84,18 +84,45 @@ test("exposes exactly two tools", async () => { const project = tools.tools.find((tool) => tool.name === "create_project_tour"); assert.ok(project); assert.ok(project!.description!.includes("Project Tour")); + assert.ok(project!.description!.includes("readable Markdown")); + assert.ok( + project!.description!.includes("short paragraphs separated by blank lines") + ); + assert.ok(project!.description!.includes("bullet or numbered lists")); + assert.ok(project!.description!.includes("Avoid dense monolithic paragraphs")); assert.ok( project!.description!.includes("Begin with a directory-anchored overview step") ); assert.ok(project!.description!.includes("tour is scoped to a subdirectory")); - assert.ok(project!.description!.includes("Use Mermaid sparingly")); + assert.ok(project!.description!.includes("Use Mermaid when it materially clarifies")); + assert.ok( + project!.description!.includes( + "For architecture, workflow, lifecycle, or multi-module Tours, include at least one Mermaid diagram" + ) + ); assert.ok(project!.description!.includes("flowchart, sequenceDiagram")); assert.ok(project!.description!.includes("at most 3 Mermaid fences")); assert.ok(project!.description!.includes("20 KB")); const changes = tools.tools.find((tool) => tool.name === "create_changes_tour"); assert.ok(changes); assert.ok(changes!.description!.includes("Changes Tour")); + assert.ok(changes!.description!.includes("short paragraphs separated by blank lines")); assert.ok(changes!.description!.includes("**Diagram — …**")); + + for (const tool of [project, changes]) { + const properties = (tool!.inputSchema.properties ?? {}) as Record< + string, + unknown + >; + assert.match( + (properties.description as { description?: string }).description ?? "", + /readable Markdown overview/ + ); + assert.match( + (properties.steps as { description?: string }).description ?? "", + /blank lines between paragraphs/ + ); + } }); } finally { rmrf(root); From a9fcffd50d788a247a34956865be3969f9b5790e Mon Sep 17 00:00:00 2001 From: dhuyet Date: Fri, 4 Sep 2026 13:31:44 +0200 Subject: [PATCH 14/14] feat: implement CI workflow and enhance tour descriptions with clickable webview --- .github/workflows/ci.yml | 56 +++++++++ .gitignore | 1 + .tours/changes.tour | 54 ++++++++ .vscode/settings.json | 2 +- CONTEXT.md | 21 ++++ package.json | 1 + scripts/run-compiled-tests.js | 5 +- skills-lock.json | 227 ++++++++++++++++++++++++++++++++++ 8 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .tours/changes.tour create mode 100644 CONTEXT.md create mode 100644 skills-lock.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..acb0bb3d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-and-package: + name: Test and package plugin + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: | + package-lock.json + packages/description-renderer/package-lock.json + packages/mcp-server/package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install description renderer dependencies + run: npm ci --prefix packages/description-renderer + + - name: Install MCP server dependencies + run: npm ci --prefix packages/mcp-server + + - name: Run tests + run: npm test + + - name: Build VSIX artifact + run: npm run package + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v4 + with: + name: codetour-linux-x64-${{ github.sha }} + path: codetour-linux-x64-*.vsix + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index c597eed6..44be4a20 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules .vscode-test/ *.vsix .DS_Store +.agents/ diff --git a/.tours/changes.tour b/.tours/changes.tour new file mode 100644 index 00000000..588f5c61 --- /dev/null +++ b/.tours/changes.tour @@ -0,0 +1,54 @@ +{ + "$schema": "https://aka.ms/codetour-schema", + "title": "Descriptions de tour cliquables dans un webview", + "description": "Cette branche ajoute une vue agrandie pour lire la description de l'étape CodeTour active. Depuis la barre d'actions du commentaire, l'utilisateur ouvre un panneau latéral qui rend le Markdown, conserve les liens CodeTour et protège le webview contre le HTML ou les URI dangereux.\n\nElle complète aussi les consignes du serveur MCP afin que les tours générés restent lisibles et utilisent Mermaid lorsqu'un diagramme apporte une vraie vue d'ensemble.\n\nGenerated from `feature/mermaid-tour-support` (`ba7f55f65b262af892aca94fb8cb2582c0757f9b`) to `feature/clickable-tour-description-webview` (`591effd`). Les modifications locales non commitées sont exclues.", + "ref": "591effd", + "steps": [ + { + "title": "Point d'entrée dans l'interface", + "description": "La nouvelle commande `codetour.showStepDescription` est déclarée dans le manifeste et ajoutée à la barre d'actions du commentaire CodeTour. L'icône d'aperçu apparaît en première position, tandis que les commandes de navigation et de fin de tour conservent leurs rôles.\n\nLa commande est également disponible dans la palette et dans les menus associés au contexte de lecture.", + "file": "package.json", + "pattern": "codetour.showStepDescription" + }, + { + "title": "Enregistrement de la commande", + "description": "Le player relie la contribution du manifeste à `showStepDescription`. Cette petite couture garde le manifeste déclaratif et délègue toute la logique de présentation au module du webview.", + "file": "src/player/commands.ts", + "pattern": "showStepDescription" + }, + { + "title": "Ouverture de la description active", + "description": "`showStepDescription` récupère le tour et l'étape actifs, ignore proprement les cas sans description, puis passe le contenu dans le pipeline de prévisualisation existant. Les liens de fichiers, références de tours, commandes et images Mermaid bénéficient ainsi des mêmes transformations que les autres surfaces de lecture.\n\nUn seul panneau est réutilisé au fil des étapes. Son titre et son contenu sont actualisés, et les références globales sont nettoyées à sa fermeture.", + "file": "src/player/descriptionWebview.ts", + "pattern": "export async function showStepDescription" + }, + { + "title": "Liens réellement cliquables", + "description": "Le webview renvoie les clics au processus de l'extension. Les commandes sont limitées à `codetour.*` et `vscode.open`, les liens HTTP, HTTPS et mailto sont ouverts à l'extérieur, et les chemins relatifs sont résolus dans le workspace.\n\nLes objets URI encodés dans les arguments de commande sont reconstruits avant l'appel à VS Code, ce qui permet aux liens enrichis produits par CodeTour de continuer à fonctionner.", + "file": "src/player/descriptionWebview.ts", + "pattern": "async function openLink" + }, + { + "title": "Rendu Markdown centralisé et sécurisé", + "description": "Le nouveau module convertit le Markdown avec `markdown-it`, puis nettoie systématiquement le HTML avec `sanitize-html`. L'allowlist conserve le balisage de présentation, les liens, le code et les images nécessaires au renderer Mermaid, tout en supprimant scripts, attributs et protocoles non autorisés.\n\nLe document final ajoute une Content Security Policy avec nonce, reprend les couleurs du thème VS Code et intercepte les liens non locaux pour les transmettre à l'extension.\n\n**Diagram — Flux d'une description jusqu'à l'action utilisateur**\n```mermaid\nflowchart LR\n A[Description de l'étape] --> B[Pipeline CodeTour]\n B --> C[Markdown-it]\n C --> D[Sanitization HTML]\n D --> E[Webview sécurisé]\n E -->|clic| F[Validation du lien]\n F --> G[Commande VS Code ou navigateur]\n```", + "file": "src/player/markdown.ts", + "pattern": "export function renderMarkdownToHtml" + }, + { + "title": "Décision d'architecture", + "description": "L'ADR formalise la séparation entre les surfaces natives de VS Code, qui continuent de recevoir du Markdown préparé, et les surfaces HTML possédées par l'extension, qui doivent toutes passer par le renderer et le sanitizer partagés.\n\n`markdown-it` et `sanitize-html` deviennent donc des dépendances directes: leur version et leur politique de sécurité font partie du contrat de CodeTour au lieu de dépendre d'une dépendance transitive.", + "file": "docs/adr/0009-centralize-markdown-rendering-and-sanitization.md" + }, + { + "title": "Couverture du comportement et de la sécurité", + "description": "Les tests du webview vérifient le rendu d'un titre Markdown, la transmission des clics, la Content Security Policy, l'échappement du titre du panneau et la suppression des scripts ou liens `javascript:`.\n\nUn test séparé contrôle la contribution de menu pour garantir que le nouveau bouton ne remplace pas l'action `End Tour`.", + "file": "src/player/descriptionWebview.test.ts" + }, + { + "title": "Des tours MCP plus lisibles", + "description": "Le second commit enrichit les instructions exposées par `create_project_tour` et `create_changes_tour`. Les descriptions doivent utiliser des paragraphes courts séparés par des lignes vides, des listes lorsque cela clarifie une collection ou une séquence, et éviter les gros blocs de texte.\n\nLes tours d'architecture, de workflow, de cycle de vie ou multi-modules sont désormais encouragés à inclure un diagramme Mermaid quand il synthétise des relations réparties sur plusieurs étapes. Le schéma d'entrée et les tests d'intégration rendent ces attentes visibles et vérifiables.", + "file": "packages/mcp-server/src/server.ts", + "pattern": "MARKDOWN_WRITING_GUIDANCE" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 7946d697..f8280587 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,6 @@ "typescript.tsc.autoDetect": "off", "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.organizeImports": true + "source.organizeImports": "explicit" } } diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..6422f287 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,21 @@ +# CodeTour + +CodeTour guides a reader through selected places in a codebase using an ordered sequence of contextual explanations. + +## Language + +**Tour**: +An ordered walkthrough of a codebase composed of Tour Steps. +_Avoid_: Guide, presentation + +**Tour Step**: +One contextual stop in a Tour, anchored to a code location or content surface and carrying a description. +_Avoid_: Comment, slide + +**Mermaid Diagram**: +A diagram whose source belongs to a Tour description and whose rendered form remains part of that description during playback. +_Avoid_: Screenshot, generated attachment + +**Playback Surface**: +A user-visible place where a Tour or Tour Step description is presented while reading a Tour. +_Avoid_: Component, container diff --git a/package.json b/package.json index 60889aa6..9ec107f2 100644 --- a/package.json +++ b/package.json @@ -698,6 +698,7 @@ "webpack-merge": "^5.8.0" }, "scripts": { + "prototype:mermaid-height": "python3 -m http.server 4173 --directory src/player", "build:mcp": "npm --prefix packages/mcp-server run build", "build:renderer": "npm --prefix packages/description-renderer run build", "build": "npm run build:renderer && npm run build:mcp && webpack --mode production && npm run prepare:resvg", diff --git a/scripts/run-compiled-tests.js b/scripts/run-compiled-tests.js index b94e38ad..9dab205e 100644 --- a/scripts/run-compiled-tests.js +++ b/scripts/run-compiled-tests.js @@ -21,7 +21,10 @@ if (testFiles.length === 0) { process.exit(1); } -const result = spawnSync(process.execPath, ["--test", ...testFiles], { +// Some integration tests package the MCP server, whose prepack hook refreshes +// its staged renderer. Run test files serially so that refresh cannot race +// with another test process loading the staged package. +const result = spawnSync(process.execPath, ["--test", "--test-concurrency=1", ...testFiles], { stdio: "inherit" }); diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..c85f6fe5 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,227 @@ +{ + "version": 1, + "skills": { + "ask-matt": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/ask-matt/SKILL.md", + "computedHash": "e5404aef8525ca0c8fc76692567a38dfdd5d0cc1a9f8d47c02c18f29f60d92a6" + }, + "claude-handoff": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/claude-handoff/SKILL.md", + "computedHash": "c0fa5d0eede556bc7809c8461a25ec2c7db5726f338970458aa7bbf702b8ea8c" + }, + "code-review": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/code-review/SKILL.md", + "computedHash": "b4f17857c85ca60af1df7d0b623dd03c2a48419f6123e714f3d9748ca744a1bf" + }, + "codebase-design": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/codebase-design/SKILL.md", + "computedHash": "caea3cb8a8281ff829fd1a2b985044e77601b62f301adf43f689ccfc74c15f6f" + }, + "diagnosing-bugs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/diagnosing-bugs/SKILL.md", + "computedHash": "dcaaa3eb81195329f65f27574d9a67dc776160dd2e4d7798d1afb1e4a5f3695a" + }, + "domain-modeling": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/domain-modeling/SKILL.md", + "computedHash": "336547f3ff285e822fc70b69b170dc58bceef9c8ed5fad18de0046287d6be837" + }, + "git-guardrails-claude-code": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/git-guardrails-claude-code/SKILL.md", + "computedHash": "fa22f1aa2708d95cc3149640f33e085381b184a0322609930f7971c1dca11835" + }, + "grill-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grill-me/SKILL.md", + "computedHash": "5e0c683385eafd83f106ac6c9d67dfbbfe5aa4b3fe65aad114eb1055a99c818f" + }, + "grill-with-docs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/grill-with-docs/SKILL.md", + "computedHash": "a610223c9796f755b603f15ec114849a4b38b9ba3006acfa9bdf3cc56dd44dad" + }, + "grilling": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grilling/SKILL.md", + "computedHash": "4fa026e5979770347b3357ff5139e1e41d21c3a9f7335e9cd2811cb5b8d32f2f" + }, + "handoff": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/handoff/SKILL.md", + "computedHash": "9ea2a5de5ca2d717f913356de982fdc4fe27d28a300c16598cd43a4865491008" + }, + "implement": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/implement/SKILL.md", + "computedHash": "130cac2d72bfde8cd526bf3b754211e2fe00e84bc4d9f9c56f749b9541a3afad" + }, + "implement-spec": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/implement-spec/SKILL.md", + "computedHash": "1bbece7b74c00d938ecb79e2337310d8c3c2c307b48f105026e161601b1c6be8" + }, + "improve-codebase-architecture": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md", + "computedHash": "016f12709bff30a27419e8add978a9be70f5ca1fb6738b3be6793038e0aba631" + }, + "loop-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/loop-me/SKILL.md", + "computedHash": "d7fbcf41b5af203a6f2a7937afaaaebdd9941b0aa4cc3654b52daec01d3fb564" + }, + "migrate-to-shoehorn": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/migrate-to-shoehorn/SKILL.md", + "computedHash": "7d31e494a384d1a5b88d1dec6e1f50e673ef1a60f7b0b6e92babfbb242f79b62" + }, + "prototype": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/prototype/SKILL.md", + "computedHash": "62979fe039ef64407b258f8824db41b9185f788638747df803032df4153c2aae" + }, + "research": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/research/SKILL.md", + "computedHash": "2c8b768d5f0309eaea9f92ef740101e813645b39aef88fb82733a52eae23dc0b" + }, + "resolving-merge-conflicts": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/resolving-merge-conflicts/SKILL.md", + "computedHash": "436698459190d8e9f03dc32ada08401b56e352fb6a19bf71a2227fad8b80d98f" + }, + "retro": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/retro/SKILL.md", + "computedHash": "371bfc87b0fbb221e2e802e51385b5e1461c052cfa1186b18d4b441c00383eb5" + }, + "scaffold-exercises": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/scaffold-exercises/SKILL.md", + "computedHash": "0f8154a7be9fc5ebe6ae0550d520259fadfdd512c166cb7f137f8e697aae4017" + }, + "setup-matt-pocock-skills": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md", + "computedHash": "a45163cba56f72f5f224c46557c047d51547147a29e383e5a769ec39eecd1188" + }, + "setup-pre-commit": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/setup-pre-commit/SKILL.md", + "computedHash": "4ae1ad6988eb61dbc600627bc12e061215d96ced504632d00f0d9de5905eadfa" + }, + "setup-ts-deep-modules": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/setup-ts-deep-modules/SKILL.md", + "computedHash": "0b0e7124ad1272d59e44bcb4224275e76421351294a91480ada62234158c1023" + }, + "tdd": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/tdd/SKILL.md", + "computedHash": "c1ed8cd854c64d4d226097255d3fa662dae4a758a1462fadd63cc10e413d88d7" + }, + "teach": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/teach/SKILL.md", + "computedHash": "d5f74fa9a34961cf4df339019f7cd1ebf84163cf8b789829be26342e5ceba5a1" + }, + "to-questionnaire": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/to-questionnaire/SKILL.md", + "computedHash": "e2cb00bfeb4243f1384bf7e106185ccb69fd7d45ff6a3b04bec4102f505aba31" + }, + "to-spec": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-spec/SKILL.md", + "computedHash": "0ee8caf20fc7df94db53e76f42588fb01eee88c6f8dc97e7a3a0565be8978e74" + }, + "to-tickets": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-tickets/SKILL.md", + "computedHash": "4aba8639f46b55ede011866f83e9e87ae10482b72c14848389e5801eba0b37bf" + }, + "triage": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/triage/SKILL.md", + "computedHash": "363ca4f1b97ca5227d41d3973178ab301d7c498ba41b466dfe30b6163f6d72b5" + }, + "wait-what": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/wait-what/SKILL.md", + "computedHash": "179a857fabd08c894e8e5b30baa7488147760ee1d58696ebd24bd4f6ead21699" + }, + "wayfinder": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/wayfinder/SKILL.md", + "computedHash": "f53b17850d8d4d68e8b861cfc7de69580335851d7fe7bc3acdf37505f46c9940" + }, + "wizard": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/wizard/SKILL.md", + "computedHash": "6b98bd36db34eb5584d937b745e6e68a317afa0807a426fe8717ab196242ae70" + }, + "writing-beats": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-beats/SKILL.md", + "computedHash": "1d1c32479fa0774738c7baf264026e937f4a75ef7c20bd6c1b585681cadb523c" + }, + "writing-for-agents": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/writing-for-agents/SKILL.md", + "computedHash": "831e996c177c6c5233eb3bb472a33edee2b3ce1ee5a6d7e8b696c1c3fb4eda3d" + }, + "writing-fragments": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-fragments/SKILL.md", + "computedHash": "e97c829457cb9119aa6a636b989b3c3e273e55c9bcf27b6d281d8132e04ca972" + }, + "writing-shape": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-shape/SKILL.md", + "computedHash": "175889218686932bda7724425b97a0a10a7679db51447c40a1a0d3a5e6f2130e" + } + } +}