diff --git a/.agents/skills/ask-matt/PHASE-BOUNDARIES.md b/.agents/skills/ask-matt/PHASE-BOUNDARIES.md new file mode 100644 index 00000000..fb58ef9f --- /dev/null +++ b/.agents/skills/ask-matt/PHASE-BOUNDARIES.md @@ -0,0 +1,55 @@ +# Phase boundaries + +A **phase** is a chunk of work inside a session: the grilling, the implementation, the QA. The definition is fuzzy on purpose: a phase ends when you think *"ok, we're done with that"*. + +The **phase boundary** is the gap between two phases, and it is the only place this decision belongs. Mid-phase there is no decision to make: continue, or split the work that's left into subagents. Compacting mid-phase makes the agent lose the thread. + +## The five options + +| Option | What it does | +| ------------ | --------------------------------------------------------------- | +| **Continue** | Stay in the session. No context switch at all. | +| **`/clear`** | Empty the context window and start from nothing. | +| **`/handoff`** | Write a portable markdown file and seed a session anywhere with it. | +| **Subagent** | Send the task to its own context window and get a report back. | +| **`/compact`** | Compress this context and seed a fresh session with the summary. | + +## The tree + +Work top to bottom at the boundary. The first **yes** wins. + +**1. Can you continue in this session?** Two things make the answer yes: the next phase needs this phase as a **primary source**, or you have enough [smart zone](https://www.aihero.dev/ai-coding-dictionary/smart-zone) left (~150k tokens) for the next phase to fit. Grilling → implementation is the standard yes: the implementation wants the reasoning verbatim, not a summary of it. Continue costs nothing and loses nothing, so rule it out before anything else. + +**2. Is the context irrelevant to what comes next?** Is everything in this session (the exploration, the decisions, the dead ends) disposable? If so, **`/clear`**. It is the cheapest move on the board: it takes no time and hands back the whole window. `/clear` also isn't terminal: the old session stays resumable. + +The cost of getting this wrong is one-way. Clear a *relevant* context and you lose the **why** behind what you built, and no amount of reading the diff back gets it returned. + +**3. Do you need to hand off?** `/handoff` is narrow. You need it only when you are: + +- swapping to a **new harness** (Claude → Codex), +- moving to a **new directory** or repo, +- sending the work to a **colleague**, +- or forking a side task you found **mid-phase** without derailing what you're doing. + +That list is the whole clause. What `/handoff` buys is **portability**: a file that travels. If nothing is travelling, you don't need it. + +**4. Can the task be done AFK?** Is it scoped tightly enough to run with you away from the keyboard, no steering? Then send it to a **subagent** and leave this session untouched. Automated review is the standard case: the agent reads the diff and reports, and you aren't needed while it does. + +**5. Otherwise, `/compact`.** Relevant context, same harness, same directory, and you need to stay in the loop: this is where the tree lands, and it lands here often. Pass it an instruction (`/compact we're going to QA this area`) so the summary keeps what the next phase needs. + +`/compact` is the **default, not the first reach**. It sits at the bottom because the four questions above it are all cheaper or more precise. The failure mode when people start here is a fresh session that is confidently wrong about a decision the summary flattened. + +## Primary and secondary sources + +Every move except **Continue** turns a **primary source** into a **secondary source**: the session as it happened, replaced by a summary of it. The trade is always the same shape: + +| Source | Information | Noise | Room to move | +| --------------------------------- | ----------- | ----- | ------------ | +| Primary (Continue) | Full | Lots | Little | +| Secondary (`/compact`, `/handoff`) | Lossy | Less | Lots | + +This is why question 1 comes first. You only pay the lossiness when staying costs more than it saves. + +## These are judgement calls + +The questions are not objective: each has taste in it, and the same boundary can go two ways on two days. The value is in asking them **in order**, at the boundary rather than in the middle of the work. diff --git a/.agents/skills/ask-matt/SKILL.md b/.agents/skills/ask-matt/SKILL.md new file mode 100644 index 00000000..ae8eb9b2 --- /dev/null +++ b/.agents/skills/ask-matt/SKILL.md @@ -0,0 +1,90 @@ +--- +name: ask-matt +description: Ask which skill or flow fits your situation. A router over the skills in this repo. +disable-model-invocation: true +--- + +# Ask Matt + +You don't remember every skill, so ask. + +A **flow** is a path through the skills. Most paths run along one **main flow**, and two **on-ramps** merge onto it. Everything else is standalone, or a vocabulary layer that runs underneath. + +## The main flow: idea → ship + +The route most work travels. You have an idea and want it built. + +1. **`/grill-with-docs`** sharpens the idea by interview. Start here whenever you are **working in a working directory**: it's stateful, retaining what it learns in `CONTEXT.md` and ADRs. (No working directory? Use `/grill-me` instead, covered under Standalone. Both run the same `/grilling` primitive; `grill-with-docs` is the one that leaves a paper trail, which makes it the better of the two whenever a repo is there to leave it in.) +2. **Branch: can you settle every question in conversation?** If a question needs a runnable answer (state, business logic, a UI you have to see), detour through a prototype, bridged by **`/handoff`** in both directions (a prototype lives in its own directory, which is exactly what `/handoff` is for; see Phase boundaries): + - **`/handoff`** out, then open a fresh session against that file, + - **`/prototype`** to answer the question with throwaway code, + - **`/handoff`** back what you learned, and reference it from the original idea thread. +3. **Branch: is this a multi-session build?** + - **Yes** → **`/to-spec`** (turn the thread into a spec), then **`/to-tickets`** to split it into tracer-bullet tickets, each declaring its **blocking edges**. On a local tracker that's one file per ticket under `.scratch//issues/`, worked blockers-first by hand; on a real tracker the edges become native blocking links, so any ticket whose blockers are done can be grabbed: kick off **`/implement`** per ticket, **`/clear`ing context between each one**. Each ticket is self-contained, so the last one's context is disposable. + - **No** → **`/implement`** right here, in the same context window. + + Either way, **`/implement`** builds each issue by driving **`/tdd`** internally (one red-green slice at a time), then closes out by running **`/code-review`**, a two-axis review (Standards + Spec) of the diff, before committing. Reach for **`/tdd`** on its own when you just want to build a concrete behaviour test-first without a full spec, and **`/code-review`** on its own whenever you want to review a branch or PR against a fixed point. + +### Context hygiene + +Keep steps 1–3 in **one unbroken context window** (don't compact or clear until after `/to-tickets`) so the grilling, spec, and tickets all build on the same thinking. Each `/implement` then starts fresh, working from the ticket. + +The limit on this is the **[smart zone](https://www.aihero.dev/ai-coding-dictionary/smart-zone)**: the window (~150k tokens on state-of-the-art models) within which the model still reasons sharply. If a session approaches it before `/to-tickets`, don't push on degraded; `/compact` at the nearest phase boundary and carry on (see Phase boundaries). + +## On-ramps + +A starting situation that generates work, then merges onto the main flow. + +- **Bugs and requests piling up** → **`/triage`**. It moves issues through triage roles and produces agent-ready issues, which **`/implement`** later picks up. + + Triage is only for issues **you didn't create**: bug reports, incoming feature requests, anything that arrives raw. Tickets that `/to-tickets` produced are already agent-ready, so **don't triage them**. + +- **Something's broken** → **`/diagnosing-bugs`**. For the hard ones: the bug that resists a first glance, the intermittent flake, the regression that crept in between two known-good states. It refuses to theorise until it has a **tight feedback loop** (one command that already goes red on *this* bug), then fixes with a regression test. Its post-mortem hands off to **`/improve-codebase-architecture`** when the real finding is that there's no good seam to lock the bug down. + +- **A huge, foggy effort: a greenfield project or a huge feature build, too big for one session** → **`/wayfinder`**, the most cognitively demanding flow here. When the way from here to the destination isn't visible yet, it charts a **shared map** of **decision tickets** on the issue tracker and resolves them one at a time, producing **decisions, not deliverables**, until the fog is pushed back and the way is clear. Where **`/grill-with-docs`** sharpens an idea you can hold in one session, wayfinder is for the idea you can't, and it's slower and denser, so save it for exactly that, never a well-scoped feature. + + When the map clears, **it hands off, it doesn't build**: merge onto the main flow at **`/to-spec`**, which collapses the map's linked decisions into a buildable plan, then `/to-tickets` and `/implement` as usual. Looping the map straight into `/implement` skips that collapse and throws the linked detail away, so go straight to `/implement` only when the effort turned out genuinely small. + +## Codebase health + +Not feature work, just upkeep. + +- **`/improve-codebase-architecture`** runs whenever you have a spare moment to keep the codebase good for agents to operate in. It surfaces **deepening opportunities**; picking one _generates an idea_ you can take into the main flow at `/grill-with-docs`. It's the survey that finds the candidates; **`/codebase-design`** (below) is the bench you design the chosen one on. + +## Vocabulary underneath + +Two model-invoked references that run *beneath* the other skills, each the single source of truth for its vocabulary. Reach for them directly when the **words**, not the process, are the problem; or let the skills above pull them in. + +- **`/domain-modeling`**: sharpen the project's *domain* language: challenge a fuzzy term, resolve an overloaded word ("account" doing three jobs), record a hard-to-reverse decision as an ADR. It's the active discipline `/grill-with-docs` drives to keep `CONTEXT.md` a clean glossary. +- **`/codebase-design`** is the deep-module vocabulary (module, interface, depth, seam, adapter, leverage, locality) for designing a module's *shape*: a lot of behaviour behind a small interface at a clean seam. `/tdd` and `/improve-codebase-architecture` both speak it. + +## Phase boundaries + +A **phase** is a chunk of work inside a session: the grilling, the implementation, the QA. At the **boundary** between two of them you have five options, and picking between them is the fuzziest decision in this whole map: + +- **Continue**: stay put. Costs nothing, loses nothing. +- **`/clear`**: empty the window, when nothing here matters to what's next. +- **`/handoff`** writes a portable markdown file. Narrow: only for a **new harness**, a **new directory**, a **colleague**, or forking a side task **mid-phase**. What it buys is portability. +- **Subagent**: send a tightly-scoped task to its own window and get a report back. +- **`/compact`** compresses this context and seeds a fresh session with it. The **default**, at the bottom of the tree rather than the first reach. + +Read [PHASE-BOUNDARIES.md](PHASE-BOUNDARIES.md) for the ordered tree: the five questions, the reasoning behind each branch, and why the primary-source cost makes **Continue** the one to rule out first. Make the decision **at** a boundary; mid-phase, continue or split the rest into subagents. + +## Standalone + +Off the main flow entirely. + +- **`/grill-me`**: the same relentless interview as `/grill-with-docs`, but **stateless**: it saves nothing locally and builds no `CONTEXT.md`. Reach for it when you are **not working in a working directory** (sharpening a plan, a design, a piece of writing, anything with no repo under it). If you are in a working directory, use `/grill-with-docs` instead: it runs the same interview and leaves a paper trail, so it is strictly the better one. +- **`/grilling`** is the interview primitive itself: rounds, the frontier, facts are the agent's job and decisions are yours. `/grill-me` and `/grill-with-docs` are the two named ways in, and `/triage`, `/wayfinder` and `/improve-codebase-architecture` all run it internally. Reach for it directly only when you want the interview with no wrapper around it. +- **`/resolving-merge-conflicts`** works an in-progress merge or rebase conflict hunk by hunk, resolving by **intent** traced to each side's primary source rather than by picking lines, then finishes the operation. It never runs `--abort`. Standalone and off every flow: reach for it when you are already mid-conflict. +- **`/prototype`** is a small, throwaway program that answers one design question: does this state model feel right, or what should this UI look like. Throwaway is a constraint on how the code is written, not a promise to destroy it: the answer folds into the real code, and the prototype itself is kept as a **primary source** on a `prototype/` branch out of main, pointed at from the implementation issue. It's the detour in step 2 of the main flow, but reach for it any time a design question is hard to settle on paper. +- **`/research`**: delegate reading legwork to a **background agent**: it investigates a question against **primary sources**, then leaves a cited Markdown file in the repo. Keep working while it reads. The file it produces is something to take *into* the main flow at `/grill-with-docs`, since research feeds the thinking rather than replacing it. +- **`/to-questionnaire`** comes in when the thing blocking you isn't in your head or the codebase but in **someone else's**, and it writes them a questionnaire to fill in. It's the inverse of `/grill-me`: instead of interviewing you about the subject, it interviews you about the **send** (who it's going to, what you need back) and aims the questions at the gap. What comes back is material for `/grill-with-docs` or `/to-spec`. +- **`/wizard`** is for the steps only a **human** can take: provisioning infrastructure, setting up credentials or CI secrets, clicking through an unfamiliar third-party dashboard, running a one-off migration or cutover. It generates an interactive bash script that opens each URL, captures each value, and writes it into `.env` and GitHub secrets, so the procedure stops being something you re-explain to an agent every time. Model-invoked, so the agent reaches for it the moment it hits a wall only you can pass. If the agent could just do it itself, it should; this is for where a human is genuinely in the loop. +- **`/wait-what`** is the corrective for a message that didn't land. Use it mid-conversation, inside any other skill, and the agent re-pitches what it just said with the context you were missing, in plain English, using the `CONTEXT.md` vocabulary. It works after the fact; `/grill-with-docs` is the upfront cure, because a shared language agreed early is what stops the jargon arriving at all. +- **`/teach`**: learn a concept over multiple sessions, using the current directory as a stateful workspace. +- **`/writing-for-agents`** is the reference for writing documents agents consume: skills, AGENTS.md, pointed-at docs. + +## Precondition + +**`/setup-matt-pocock-skills`**: run before your first engineering flow to configure the issue tracker, triage labels, and doc layout the other skills assume. Custom issue trackers also work. diff --git a/.agents/skills/ask-matt/agents/openai.yaml b/.agents/skills/ask-matt/agents/openai.yaml new file mode 100644 index 00000000..5c60d51b --- /dev/null +++ b/.agents/skills/ask-matt/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Ask Matt" + short_description: "Find the right skill or workflow" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/claude-handoff/SKILL.md b/.agents/skills/claude-handoff/SKILL.md new file mode 100644 index 00000000..9ab14e31 --- /dev/null +++ b/.agents/skills/claude-handoff/SKILL.md @@ -0,0 +1,18 @@ +--- +name: claude-handoff +description: Hand the current conversation off to a fresh background agent that picks up the work immediately. +argument-hint: "What will the next session be used for?" +disable-model-invocation: true +--- + +Write a handoff summary of the current conversation so a fresh agent can continue the work. Instead of saving it, launch a background agent seeded with the summary as its prompt: `claude --bg --name "" ""`. It starts in the current working directory and returns immediately; the user manages it with `claude agents`. + +Always pass `-n`/`--name` with a descriptive name (e.g. `--name "Fix login bug"`); it sets the display name shown in the job list, session picker, and terminal title. + +Include a "suggested skills" section in the summary, naming which skills the next agent should call the Skill tool for. + +Do not duplicate content already captured in other artifacts (specs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead. + +Redact any sensitive information, such as API keys, passwords, or personally identifiable information, since the summary becomes the agent's prompt. + +If the user passed arguments, treat them as a description of what the next session will focus on and tailor the summary accordingly. diff --git a/.agents/skills/claude-handoff/agents/openai.yaml b/.agents/skills/claude-handoff/agents/openai.yaml new file mode 100644 index 00000000..0a7aa5da --- /dev/null +++ b/.agents/skills/claude-handoff/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Claude Handoff" + short_description: "Hand off to a background agent" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 00000000..e28d7acb --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,87 @@ +--- +name: code-review +description: "Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes: Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/spec asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to \"review since X\"." +--- + +Two-axis review of the diff between `HEAD` and a fixed point the user supplies: + +- **Standards**: does the code conform to this repo's documented coding standards? +- **Spec**: does the code faithfully implement the originating issue / spec? + +Both axes run as **parallel sub-agents** so they don't pollute each other's context, then this skill aggregates their findings. + +The issue tracker should have been provided to you. If `docs/agents/issue-tracker.md` is missing, tell the user to run `/setup-matt-pocock-skills`. + +## Process + +### 1. Pin the fixed point + +Whatever the user said is the fixed point (a commit SHA, branch name, tag, `main`, `HEAD~5`, etc.). If they didn't specify one, ask for it. + +Capture the diff command once: `git diff ...HEAD` (three-dot, so the comparison is against the merge-base). Also note the list of commits via `git log ..HEAD --oneline`. + +Before going further, confirm the fixed point resolves (`git rev-parse `) and the diff is non-empty. A bad ref or empty diff should fail here, not inside two parallel sub-agents. + +### 2. Identify the spec source + +Look for the originating spec, in this order: + +1. Issue references in the commit messages (`#123`, `Closes #45`, GitLab `!67`, etc.), fetched via the workflow in `docs/agents/issue-tracker.md`. +2. A path the user passed as an argument. +3. A spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature. +4. If nothing is found, ask the user where the spec is. If they say there isn't one, the **Spec** sub-agent will skip and report "no spec available". + +### 3. Identify the standards sources + +Anything in the repo that documents how code should be written, such as `CODING_STANDARDS.md` or `CONTRIBUTING.md`. + +On top of whatever the repo documents, the Standards axis always carries the **smell baseline** below: a fixed set of Fowler code smells (_Refactoring_, ch.3) that applies even when a repo documents nothing. Two rules bind it: + +- **The repo overrides.** A documented repo standard always wins; where it endorses something the baseline would flag, suppress the smell. +- **Always a judgement call.** Each smell is a labelled heuristic ("possible Feature Envy"), never a hard violation. Like any standard here, skip anything tooling already enforces. + +Each smell reads *what it is* → *how to fix*; match it against the diff: + +- **Mysterious Name**: a function, variable, or type whose name doesn't reveal what it does or holds. → rename it; if no honest name comes, the design's murky. +- **Duplicated Code**: the same logic shape appears in more than one hunk or file in the change. → extract the shared shape, call it from both. +- **Feature Envy**: a method that reaches into another object's data more than its own. → move the method onto the data it envies. +- **Data Clumps**: the same few fields or params keep travelling together (a type wanting to be born). → bundle them into one type, pass that. +- **Primitive Obsession**: a primitive or string standing in for a domain concept that deserves its own type. → give the concept its own small type. +- **Repeated Switches**: the same `switch`/`if`-cascade on the same type recurs across the change. → replace with polymorphism, or one map both sites share. +- **Shotgun Surgery**: one logical change forces scattered edits across many files in the diff. → gather what changes together into one module. +- **Divergent Change**: one file or module is edited for several unrelated reasons. → split so each module changes for one reason. +- **Speculative Generality**: abstraction, parameters, or hooks added for needs the spec doesn't have. → delete it; inline back until a real need shows. +- **Message Chains**: long `a.b().c().d()` navigation the caller shouldn't depend on. → hide the walk behind one method on the first object. +- **Middle Man**: a class or function that mostly just delegates onward. → cut it, call the real target direct. +- **Refused Bequest**: a subclass or implementer that ignores or overrides most of what it inherits. → drop the inheritance, use composition. + +### 4. Spawn both sub-agents in parallel + +**Standards sub-agent prompt** should include: + +- The full diff command and commit list. +- The list of standards-source files you found in step 3, **plus the smell baseline from step 3** pasted in full (the sub-agent has no other access to it). +- The brief: "Report, per file/hunk where relevant, (a) every place the diff violates a documented standard: cite the standard (file + the rule); and (b) any baseline smell you spot: name it and quote the hunk. Distinguish hard violations from judgement calls: documented-standard breaches can be hard, but baseline smells are always judgement calls, and a documented repo standard overrides the baseline. Skip anything tooling enforces. Under 400 words." + +**Spec sub-agent prompt** should include: + +- The diff command and commit list. +- The path or fetched contents of the spec. +- The brief: "Report: (a) requirements the spec asked for that are missing or partial; (b) behaviour in the diff that wasn't asked for (scope creep); (c) requirements that look implemented but where the implementation looks wrong. Quote the spec line for each finding. Under 400 words." + +If the spec is missing, skip the Spec sub-agent and note this in the final report. + +### 5. Aggregate + +Present the two reports under `## Standards` and `## Spec` headings, verbatim or lightly cleaned. Do **not** merge or rerank findings, because the two axes are deliberately separate (see _Why two axes_). + +End with a one-line summary: total findings per axis, and the worst issue _within each axis_ (if any). Don't pick a single winner across axes: that's the reranking the separation exists to prevent. + +## Why two axes + +A change can pass one axis and fail the other: + +- Code that follows every standard but implements the wrong thing → **Standards pass, Spec fail.** +- Code that does exactly what the issue asked but breaks the project's conventions → **Spec pass, Standards fail.** + +Reporting them separately stops one axis from masking the other. diff --git a/.agents/skills/code-review/agents/openai.yaml b/.agents/skills/code-review/agents/openai.yaml new file mode 100644 index 00000000..9076774b --- /dev/null +++ b/.agents/skills/code-review/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Code Review" + short_description: "Review a diff on standards and spec" diff --git a/.agents/skills/codebase-design/DEEPENING.md b/.agents/skills/codebase-design/DEEPENING.md new file mode 100644 index 00000000..cd94075c --- /dev/null +++ b/.agents/skills/codebase-design/DEEPENING.md @@ -0,0 +1,37 @@ +# Deepening + +How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [SKILL.md](SKILL.md): **module**, **interface**, **seam**, **adapter**. + +## Dependency categories + +When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam. + +### 1. In-process + +Pure computation, in-memory state, no I/O. Always deepenable: merge the modules and test through the new interface directly. No adapter needed. + +### 2. Local-substitutable + +Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface. + +### 3. Remote but owned (Ports & Adapters) + +Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. + +Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."* + +### 4. True external (Mock) + +Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter. + +## Seam discipline + +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection. +- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them. + +## Testing strategy: replace, don't layer + +- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist; delete them. +- Write new tests at the deepened module's interface. The **interface is the test surface**. +- Tests assert on observable outcomes through the interface, not internal state. +- Tests should survive internal refactors, since they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface. diff --git a/.agents/skills/codebase-design/DESIGN-IT-TWICE.md b/.agents/skills/codebase-design/DESIGN-IT-TWICE.md new file mode 100644 index 00000000..7edc861a --- /dev/null +++ b/.agents/skills/codebase-design/DESIGN-IT-TWICE.md @@ -0,0 +1,44 @@ +# Design It Twice + +When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout): your first idea is unlikely to be the best. + +Uses the vocabulary in [SKILL.md](SKILL.md): **module**, **interface**, **seam**, **adapter**, **leverage**. + +## Process + +### 1. Frame the problem space + +Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate: + +- The constraints any new interface would need to satisfy +- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md)) +- A rough illustrative code sketch to ground the constraints, not a proposal, just a way to make the constraints concrete + +Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel. + +### 2. Spawn sub-agents + +Spawn 3+ sub-agents in parallel. Each must produce a **radically different** interface for the deepened module. + +Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint: + +- Agent 1: "Minimize the interface: aim for 1–3 entry points max. Maximise leverage per entry point." +- Agent 2: "Maximise flexibility: support many use cases and extension." +- Agent 3: "Optimise for the most common caller: make the default case trivial." +- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies." + +Include both [SKILL.md](SKILL.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language. + +Each sub-agent outputs: + +1. Interface (types, methods, params, plus invariants, ordering, error modes) +2. Usage example showing how callers use it +3. What the implementation hides behind the seam +4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md)) +5. Trade-offs: where leverage is high, where it's thin + +### 3. Present and compare + +Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**. + +After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated: the user wants a strong read, not a menu. diff --git a/.agents/skills/codebase-design/SKILL.md b/.agents/skills/codebase-design/SKILL.md new file mode 100644 index 00000000..3f63c814 --- /dev/null +++ b/.agents/skills/codebase-design/SKILL.md @@ -0,0 +1,114 @@ +--- +name: codebase-design +description: Shared vocabulary for designing deep modules. Use when the user wants to design or improve a module's interface, find deepening opportunities, decide where a seam goes, make code more testable or AI-navigable, or when another skill needs the deep-module vocabulary. +--- + +# Codebase Design + +Design **deep modules**: a lot of behaviour behind a small interface, placed at a clean seam, testable through that interface. Use this language and these principles wherever code is being designed or restructured. The aim is leverage for callers, locality for maintainers, and testability for everyone. + +## Glossary + +Use these terms exactly: don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point. + +**Module**: anything with an interface and an implementation. Deliberately scale-agnostic: a function, class, package, or tier-spanning slice. _Avoid_: unit, component, service. + +**Interface**: everything a caller must know to use the module correctly: the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. _Avoid_: API, signature (too narrow, they refer only to the type-level surface). + +**Implementation**: what's inside a module, its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise. + +**Depth**: leverage at the interface. The amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface, **shallow** when the interface is nearly as complex as the implementation. + +**Seam** _(Michael Feathers)_: a place where you can alter behaviour without editing in that place; the *location* at which a module's interface lives. Where to put the seam is its own design decision, distinct from what goes behind it. _Avoid_: boundary (overloaded with DDD's bounded context). + +**Adapter**: a concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside). + +**Leverage**: what callers get from depth. More capability per unit of interface they learn. One implementation pays back across N call sites and M tests. + +**Locality**: what maintainers get from depth. Change, bugs, knowledge, and verification concentrate in one place rather than spreading across callers. Fix once, fixed everywhere. + +## Deep vs shallow + +**Deep module** = small interface + lots of implementation: + +``` +┌─────────────────────┐ +│ Small Interface │ ← Few methods, simple params +├─────────────────────┤ +│ │ +│ Deep Implementation│ ← Complex logic hidden +│ │ +└─────────────────────┘ +``` + +**Shallow module** = large interface + little implementation (avoid): + +``` +┌─────────────────────────────────┐ +│ Large Interface │ ← Many methods, complex params +├─────────────────────────────────┤ +│ Thin Implementation │ ← Just passes through +└─────────────────────────────────┘ +``` + +When designing an interface, ask: + +- Can I reduce the number of methods? +- Can I simplify the parameters? +- Can I hide more complexity inside? + +## Principles + +- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts; they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. +- **The deletion test.** Imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. +- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape. +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. + +## Designing for testability + +Good interfaces make testing natural: + +1. **Accept dependencies, don't create them.** + + ```typescript + // Testable + function processOrder(order, paymentGateway) {} + + // Hard to test + function processOrder(order) { + const gateway = new StripeGateway(); + } + ``` + +2. **Return results, don't produce side effects.** + + ```typescript + // Testable + function calculateDiscount(cart): Discount {} + + // Hard to test + function applyDiscount(cart): void { + cart.total -= discount; + } + ``` + +3. **Small surface area.** Fewer methods = fewer tests needed. Fewer params = simpler test setup. + +## Relationships + +- A **Module** has exactly one **Interface** (the surface it presents to callers and tests). +- **Depth** is a property of a **Module**, measured against its **Interface**. +- A **Seam** is where a **Module**'s **Interface** lives. +- An **Adapter** sits at a **Seam** and satisfies the **Interface**. +- **Depth** produces **Leverage** for callers and **Locality** for maintainers. + +## Rejected framings + +- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead. +- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow: interface here includes every fact a caller must know. +- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**. + +## Going deeper + +- **Deepening a cluster given its dependencies**, see [DEEPENING.md](DEEPENING.md): dependency categories, seam discipline, and replace-don't-layer testing. +- **Exploring alternative interfaces**, see [DESIGN-IT-TWICE.md](DESIGN-IT-TWICE.md): spin up parallel sub-agents to design the interface several radically different ways, then compare on depth, locality, and seam placement. diff --git a/.agents/skills/codebase-design/agents/openai.yaml b/.agents/skills/codebase-design/agents/openai.yaml new file mode 100644 index 00000000..3180715e --- /dev/null +++ b/.agents/skills/codebase-design/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Codebase Design" + short_description: "Vocabulary for deep-module design" diff --git a/.agents/skills/diagnosing-bugs/SKILL.md b/.agents/skills/diagnosing-bugs/SKILL.md new file mode 100644 index 00000000..061c25a5 --- /dev/null +++ b/.agents/skills/diagnosing-bugs/SKILL.md @@ -0,0 +1,138 @@ +--- +name: diagnosing-bugs +description: Diagnosis loop for hard bugs and performance regressions. Use when the user says "diagnose"/"debug this", or reports something broken/throwing/failing/slow. +--- + +# Diagnosing Bugs + +A discipline for hard bugs. Skip phases only when explicitly justified. + +When exploring the codebase, read `CONTEXT.md` (if it exists) to get a clear mental model of the relevant modules, and check ADRs in the area you're touching. + +## Redact + +This skill has you show commands, outputs and captured artifacts. **Redact every secret first**: write `` in its place. Build loops against env vars, so the credential stays in the environment rather than in what you show. Captured artifacts carry auth headers: quote only the lines that carry the signal. + +If the redacted output is not enough to diagnose the bug, say so and ask the user. + +## Phase 1: Build a feedback loop + +**This is the skill.** Everything else is mechanical. If you have a **tight** pass/fail signal for the bug (one that goes red on _this_ bug), you will find the cause; bisection, hypothesis-testing, and instrumentation all just consume it. If you don't have one, no amount of staring at code will save you. + +Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.** + +### Ways to construct one, in roughly this order + +1. **Failing test** at whatever seam reaches the bug: unit, integration, e2e. +2. **Curl / HTTP script** against a running dev server. +3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. +4. **Headless browser script** (Playwright / Puppeteer) that drives the UI and asserts on DOM/console/network. +5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. +6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. +7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. +8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. +9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. + +Build the right feedback loop, and the bug is 90% fixed. + +### Tighten the loop + +Treat the loop as a product. Once you have _a_ loop, **tighten** it: + +- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) +- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) +- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) + +A 30-second flaky loop is barely better than no loop; a 2-second deterministic one is tight, a debugging superpower. + +### Non-deterministic bugs + +The goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not, so keep raising the rate until it's debuggable. + +### When you genuinely cannot build a loop + +Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a redacted captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop. + +### Completion criterion: a tight loop that goes red + +Phase 1 is done when the loop is **tight** and **red-capable**: you can name **one command** (a script path, a test invocation, a curl) that you have **already run at least once** (show the invocation and its output, redacted), and that is: + +- [ ] **Red-capable**: it drives the actual bug code path and asserts the **user's exact symptom**, so it can go red on this bug and green once fixed. Not "runs without erroring"; it must be able to _catch this specific bug_. +- [ ] **Deterministic**: same verdict every run (flaky bugs: a pinned, high reproduction rate, per above). +- [ ] **Fast**: seconds, not minutes. +- [ ] **Agent-runnable**: you can run it unattended; a human in the loop only via `scripts/hitl-loop.template.sh`. + +If you catch yourself reading code to build a theory before this command exists, **stop: jumping straight to a hypothesis is the exact failure this skill prevents.** No red-capable command, no Phase 2. + +## Phase 2: Reproduce + minimise + +Run the loop. Watch it go red as the bug appears. + +Confirm: + +- [ ] The loop produces the failure mode the **user** described, not a different failure that happens to be nearby. Wrong bug = wrong fix. +- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against). +- [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it. + +### Minimise + +Once it's red, shrink the repro to the **smallest scenario that still goes red**. Cut inputs, callers, config, data, and steps **one at a time**, re-running the loop after each cut, and keep only what's load-bearing for the failure. + +Why bother: a minimal repro shrinks the hypothesis space in Phase 3 (fewer moving parts left to suspect) and becomes the clean regression test in Phase 5. + +Done when **every remaining element is load-bearing**: removing any one of them makes the loop go green. + +Do not proceed until you have reproduced **and** minimised. + +## Phase 3: Hypothesise + +Generate **3–5 ranked hypotheses** before testing any of them. Single-hypothesis generation anchors on the first plausible idea. + +Each hypothesis must be **falsifiable**: state the prediction it makes. + +> Format: "If is the cause, then will make the bug disappear / will make it worse." + +If you cannot state the prediction, the hypothesis is a vibe: discard or sharpen it. + +**Show the ranked list to the user before testing.** They often have domain knowledge that re-ranks instantly ("we just deployed a change to #3"), or know hypotheses they've already ruled out. Cheap checkpoint, big time saver. Don't block on it; proceed with your ranking if the user is AFK. + +## Phase 4: Instrument + +Each probe must map to a specific prediction from Phase 3. **Change one variable at a time.** + +Tool preference: + +1. **Debugger / REPL inspection** if the env supports it. One breakpoint beats ten logs. +2. **Targeted logs** at the boundaries that distinguish hypotheses. +3. Never "log everything and grep". + +**Tag every debug log** with a unique prefix, e.g. `[DEBUG-a4f2]`. Cleanup at the end becomes a single grep. Untagged logs survive; tagged logs die. + +**Perf branch.** For performance regressions, logs are usually wrong. Instead: establish a baseline measurement (timing harness, `performance.now()`, profiler, query plan), then bisect. Measure first, fix second. + +## Phase 5: Fix + regression test + +Write the regression test **before the fix**, but only if there is a **correct seam** for it. + +A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence. + +**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase. + +If a correct seam exists: + +1. Turn the minimised repro into a failing test at that seam. +2. Watch it fail. +3. Apply the fix. +4. Watch it pass. +5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario. + +## Phase 6: Cleanup + +Required before declaring done: + +- [ ] Original repro no longer reproduces (re-run the Phase 1 loop) +- [ ] Regression test passes (or absence of seam is documented) +- [ ] All `[DEBUG-...]` instrumentation removed (`grep` the prefix) +- [ ] Throwaway prototypes deleted (or moved to a clearly-marked debug location) +- [ ] The hypothesis that turned out correct is stated in the commit / PR message, so the next debugger learns diff --git a/.agents/skills/diagnosing-bugs/agents/openai.yaml b/.agents/skills/diagnosing-bugs/agents/openai.yaml new file mode 100644 index 00000000..a13a755a --- /dev/null +++ b/.agents/skills/diagnosing-bugs/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Diagnosing Bugs" + short_description: "Diagnose hard bugs and regressions" diff --git a/.agents/skills/diagnosing-bugs/scripts/hitl-loop.template.sh b/.agents/skills/diagnosing-bugs/scripts/hitl-loop.template.sh new file mode 100644 index 00000000..24319846 --- /dev/null +++ b/.agents/skills/diagnosing-bugs/scripts/hitl-loop.template.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Human-in-the-loop reproduction loop. +# Copy this file, edit the steps below, and run it. +# The agent runs the script; the user follows prompts in their terminal. +# +# Usage: +# bash hitl-loop.template.sh +# +# Two helpers: +# step "" → show instruction, wait for Enter +# capture VAR "" → show question, read response into VAR +# +# At the end, captured values are printed as KEY=VALUE for the agent to parse. +# +# `capture` prints its value back to the terminal, where the agent reads it, +# so capture observations, and leave signing in to the user as a `step`. + +set -euo pipefail + +step() { + printf '\n>>> %s\n' "$1" + read -r -p " [Enter when done] " _ +} + +capture() { + local var="$1" question="$2" answer + printf '\n>>> %s\n' "$question" + read -r -p " > " answer + printf -v "$var" '%s' "$answer" +} + +# --- edit below --------------------------------------------------------- + +step "Open the app at http://localhost:3000 and sign in." + +capture ERRORED "Click the 'Export' button. Did it throw an error? (y/n)" + +capture ERROR_MSG "Paste the error message (or 'none'):" + +# --- edit above --------------------------------------------------------- + +printf '\n--- Captured ---\n' +printf 'ERRORED=%s\n' "$ERRORED" +printf 'ERROR_MSG=%s\n' "$ERROR_MSG" diff --git a/.agents/skills/domain-modeling/ADR-FORMAT.md b/.agents/skills/domain-modeling/ADR-FORMAT.md new file mode 100644 index 00000000..d7e61f30 --- /dev/null +++ b/.agents/skills/domain-modeling/ADR-FORMAT.md @@ -0,0 +1,47 @@ +# ADR Format + +ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc. + +Create the `docs/adr/` directory lazily: only when the first ADR is needed. + +## Template + +```md +# {Short title of the decision} + +{1-3 sentences: what's the context, what did we decide, and why.} +``` + +That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why*, not in filling out sections. + +## Optional sections + +Only include these when they add genuine value. Most ADRs won't need them. + +- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`): useful when decisions are revisited +- **Considered Options**: only when the rejected alternatives are worth remembering +- **Consequences**: only when non-obvious downstream effects need to be called out + +## Numbering + +Scan `docs/adr/` for the highest existing number and increment by one. + +## When to offer an ADR + +All three of these must be true: + +1. **Hard to reverse**: the cost of changing your mind later is meaningful +2. **Surprising without context**: a future reader will look at the code and wonder "why on earth did they do it this way?" +3. **The result of a real trade-off**: there were genuine alternatives and you picked one for specific reasons + +If a decision is easy to reverse, skip it: you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing." + +### What qualifies + +- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres." +- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP." +- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library: just the ones that would take a quarter to swap out. +- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s. +- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate. +- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract." +- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it; otherwise someone will suggest GraphQL again in six months. diff --git a/.agents/skills/domain-modeling/CONTEXT-FORMAT.md b/.agents/skills/domain-modeling/CONTEXT-FORMAT.md new file mode 100644 index 00000000..79bbb32f --- /dev/null +++ b/.agents/skills/domain-modeling/CONTEXT-FORMAT.md @@ -0,0 +1,60 @@ +# CONTEXT.md Format + +## Structure + +```md +# {Context Name} + +{One or two sentence description of what this context is and why it exists.} + +## Language + +**Order**: +{A one or two sentence description of the term} +_Avoid_: Purchase, transaction + +**Invoice**: +A request for payment sent to a customer after delivery. +_Avoid_: Bill, payment request + +**Customer**: +A person or organization that places orders. +_Avoid_: Client, buyer, account +``` + +## Rules + +- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`. +- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does. +- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs. +- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine. + +## Single vs multi-context repos + +**Single context (most repos):** One `CONTEXT.md` at the repo root. + +**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other: + +```md +# Context Map + +## Contexts + +- [Ordering](./src/ordering/CONTEXT.md): receives and tracks customer orders +- [Billing](./src/billing/CONTEXT.md): generates invoices and processes payments +- [Fulfillment](./src/fulfillment/CONTEXT.md): manages warehouse picking and shipping + +## Relationships + +- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking +- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices +- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money` +``` + +The skill infers which structure applies: + +- If `CONTEXT-MAP.md` exists, read it to find contexts +- If only a root `CONTEXT.md` exists, single context +- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved + +When multiple contexts exist, infer which one the current topic relates to. If unclear, ask. diff --git a/.agents/skills/domain-modeling/SKILL.md b/.agents/skills/domain-modeling/SKILL.md new file mode 100644 index 00000000..9b97707e --- /dev/null +++ b/.agents/skills/domain-modeling/SKILL.md @@ -0,0 +1,74 @@ +--- +name: domain-modeling +description: Build and sharpen a project's domain model. Use when discussing codebase terminology, writing or editing a CONTEXT.md, or recording or editing an ADR. +--- + +# Domain Modeling + +Actively build and sharpen the project's domain model as you design. This is the *active* discipline: challenging terms, inventing edge-case scenarios, and writing the glossary and decisions down the moment they crystallise. (Merely *reading* `CONTEXT.md` for vocabulary is not this skill: that's a one-line habit any skill can do. This skill is for when you're changing the model, not just consuming it.) + +## File structure + +Most repos have a single context: + +``` +/ +├── CONTEXT.md +├── docs/ +│ └── adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives: + +``` +/ +├── CONTEXT-MAP.md +├── docs/ +│ └── adr/ ← system-wide decisions +├── src/ +│ ├── ordering/ +│ │ ├── CONTEXT.md +│ │ └── docs/adr/ ← context-specific decisions +│ └── billing/ +│ ├── CONTEXT.md +│ └── docs/adr/ +``` + +Create files lazily: only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed. + +## During the session + +### Challenge against the glossary + +When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y. Which is it?" + +### Sharpen fuzzy language + +When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account': do you mean the Customer or the User? Those are different things." + +### Discuss concrete scenarios + +When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts. + +### Cross-reference with code + +When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible. Which is right?" + +### Update CONTEXT.md inline + +When a term is resolved, update `CONTEXT.md` right there. Don't batch these up: capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md). + +`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else. + +### Offer ADRs sparingly + +Only offer to create an ADR when all three are true: + +1. **Hard to reverse**: the cost of changing your mind later is meaningful +2. **Surprising without context**: a future reader will wonder "why did they do it this way?" +3. **The result of a real trade-off**: there were genuine alternatives and you picked one for specific reasons + +If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md). diff --git a/.agents/skills/domain-modeling/agents/openai.yaml b/.agents/skills/domain-modeling/agents/openai.yaml new file mode 100644 index 00000000..7f1522d2 --- /dev/null +++ b/.agents/skills/domain-modeling/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Domain Modeling" + short_description: "Build and sharpen a domain model" diff --git a/.agents/skills/git-guardrails-claude-code/SKILL.md b/.agents/skills/git-guardrails-claude-code/SKILL.md new file mode 100644 index 00000000..58bcdd87 --- /dev/null +++ b/.agents/skills/git-guardrails-claude-code/SKILL.md @@ -0,0 +1,95 @@ +--- +name: git-guardrails-claude-code +description: Set up Claude Code hooks to block dangerous git commands (push, reset --hard, clean, branch -D, etc.) before they execute. Use when user wants to prevent destructive git operations, add git safety hooks, or block git push/reset in Claude Code. +--- + +# Setup Git Guardrails + +Sets up a PreToolUse hook that intercepts and blocks dangerous git commands before Claude executes them. + +## What Gets Blocked + +- `git push` (all variants including `--force`) +- `git reset --hard` +- `git clean -f` / `git clean -fd` +- `git branch -D` +- `git checkout .` / `git restore .` + +When blocked, Claude sees a message telling it that it does not have authority to access these commands. + +## Steps + +### 1. Ask scope + +Ask the user: install for **this project only** (`.claude/settings.json`) or **all projects** (`~/.claude/settings.json`)? + +### 2. Copy the hook script + +The bundled script is at: [scripts/block-dangerous-git.sh](scripts/block-dangerous-git.sh) + +Copy it to the target location based on scope: + +- **Project**: `.claude/hooks/block-dangerous-git.sh` +- **Global**: `~/.claude/hooks/block-dangerous-git.sh` + +Make it executable with `chmod +x`. + +### 3. Add hook to settings + +Add to the appropriate settings file: + +**Project** (`.claude/settings.json`): + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous-git.sh" + } + ] + } + ] + } +} +``` + +**Global** (`~/.claude/settings.json`): + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/block-dangerous-git.sh" + } + ] + } + ] + } +} +``` + +If the settings file already exists, merge the hook into the existing `hooks.PreToolUse` array. Don't overwrite other settings. + +### 4. Ask about customization + +Ask if user wants to add or remove any patterns from the blocked list. Edit the copied script accordingly. + +### 5. Verify + +Run a quick test: + +```bash +echo '{"tool_input":{"command":"git push origin main"}}' | +``` + +Should exit with code 2 and print a BLOCKED message to stderr. diff --git a/.agents/skills/git-guardrails-claude-code/agents/openai.yaml b/.agents/skills/git-guardrails-claude-code/agents/openai.yaml new file mode 100644 index 00000000..3f5d756f --- /dev/null +++ b/.agents/skills/git-guardrails-claude-code/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Git Guardrails for Claude Code" + short_description: "Block dangerous git commands" diff --git a/.agents/skills/git-guardrails-claude-code/scripts/block-dangerous-git.sh b/.agents/skills/git-guardrails-claude-code/scripts/block-dangerous-git.sh new file mode 100755 index 00000000..c40b59cb --- /dev/null +++ b/.agents/skills/git-guardrails-claude-code/scripts/block-dangerous-git.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command') + +DANGEROUS_PATTERNS=( + "git push" + "git reset --hard" + "git clean -fd" + "git clean -f" + "git branch -D" + "git checkout \." + "git restore \." + "push --force" + "reset --hard" +) + +for pattern in "${DANGEROUS_PATTERNS[@]}"; do + if echo "$COMMAND" | grep -qE "$pattern"; then + echo "BLOCKED: '$COMMAND' matches dangerous pattern '$pattern'. The user has prevented you from doing this." >&2 + exit 2 + fi +done + +exit 0 diff --git a/.agents/skills/grill-me/SKILL.md b/.agents/skills/grill-me/SKILL.md new file mode 100644 index 00000000..3947ff9c --- /dev/null +++ b/.agents/skills/grill-me/SKILL.md @@ -0,0 +1,7 @@ +--- +name: grill-me +description: A relentless interview to sharpen a plan or design. +disable-model-invocation: true +--- + +Call the Skill tool with "grilling". diff --git a/.agents/skills/grill-me/agents/openai.yaml b/.agents/skills/grill-me/agents/openai.yaml new file mode 100644 index 00000000..4d6fb0c7 --- /dev/null +++ b/.agents/skills/grill-me/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Grill Me" + short_description: "Sharpen a plan through interview" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/grill-with-docs/SKILL.md b/.agents/skills/grill-with-docs/SKILL.md new file mode 100644 index 00000000..f33c7b19 --- /dev/null +++ b/.agents/skills/grill-with-docs/SKILL.md @@ -0,0 +1,11 @@ +--- +name: grill-with-docs +description: Sharpen a plan or design through a relentless interview while creating ADRs, a glossary, and useful Mermaid diagrams. +disable-model-invocation: true +--- + +Call the Skill tool twice, for "grilling" and "domain-modeling". + +When relationships, flows, states, sequences, or the emerging design tree are easier to understand visually, propose a Mermaid diagram. Generate it only from decisions already settled with the user, place it in the most relevant Markdown document, and update it as later answers change the design. Choose the Mermaid diagram type that best expresses the information; keep labels in the project's canonical domain language. + +Before finishing, verify every Mermaid block is syntactically valid and consistent with the glossary and ADRs. diff --git a/.agents/skills/grill-with-docs/agents/openai.yaml b/.agents/skills/grill-with-docs/agents/openai.yaml new file mode 100644 index 00000000..5dbe2780 --- /dev/null +++ b/.agents/skills/grill-with-docs/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Grill with Docs" + short_description: "Grill a design and write its docs" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/grilling/SKILL.md b/.agents/skills/grilling/SKILL.md new file mode 100644 index 00000000..8ca78c6d --- /dev/null +++ b/.agents/skills/grilling/SKILL.md @@ -0,0 +1,28 @@ +--- +name: grilling +description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases. +--- + +Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it. + +Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled: the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round. + +Format a round like so: + +``` +❓ **Q1** - ****: + +➡️ + +--- + +❓ **Q2** - ****: + +➡️ +``` + +Each round the user answers reshapes the tree: settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one. + +Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it; don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report; ask the rest of the frontier now. The _decisions_ are the user's: put each to them and wait. + +The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding. diff --git a/.agents/skills/grilling/agents/openai.yaml b/.agents/skills/grilling/agents/openai.yaml new file mode 100644 index 00000000..ddbdb961 --- /dev/null +++ b/.agents/skills/grilling/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Grilling" + short_description: "Stress-test thinking a round of questions at a time" diff --git a/.agents/skills/handoff/SKILL.md b/.agents/skills/handoff/SKILL.md new file mode 100644 index 00000000..2eb98a51 --- /dev/null +++ b/.agents/skills/handoff/SKILL.md @@ -0,0 +1,16 @@ +--- +name: handoff +description: Compact the current conversation into a handoff document for another agent to pick up. +argument-hint: "What will the next session be used for?" +disable-model-invocation: true +--- + +Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save to the temporary directory of the user's OS - not the current workspace. + +Include a "suggested skills" section in the document, naming which skills the next agent should call the Skill tool for. + +Do not duplicate content already captured in other artifacts (specs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead. + +Redact any sensitive information, such as API keys, passwords, or personally identifiable information. + +If the user passed arguments, treat them as a description of what the next session will focus on and tailor the doc accordingly. diff --git a/.agents/skills/handoff/agents/openai.yaml b/.agents/skills/handoff/agents/openai.yaml new file mode 100644 index 00000000..6e1d8da1 --- /dev/null +++ b/.agents/skills/handoff/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Handoff" + short_description: "Compact a conversation into a handoff" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/implement-spec/SKILL.md b/.agents/skills/implement-spec/SKILL.md new file mode 100644 index 00000000..d5097f84 --- /dev/null +++ b/.agents/skills/implement-spec/SKILL.md @@ -0,0 +1,35 @@ +--- +name: implement-spec +description: "Implement a specification in code." +disable-model-invocation: true +--- + +You have been provided a spec. This spec should have tickets associated with it, describing how to implement the spec. + +The goal is a PR which implements the entire spec on a single branch. + +The tickets are not a list of steps. They are a **task graph** with blocking relationships between them. This means there is always a **frontier** of tickets which are ready to be grabbed. + +Communication to and from subagents should be sparse. Communicate primarily through **context pointers**: to the spec, tickets, research notes, and previous commits. Don't duplicate information already available via pointers. + +**Implementer subagents** should be run in the background where possible for **maximum concurrency**. + +## Steps + +1. Read the spec and tickets. Read enough to understand the task graph. + +2. (optional) Use an **exploration subagent** to conduct any exploration required by the tickets - relevant codebase files or external documentation. Ensure the exploration subagent can save files - it should save its markdown notes in a directory outside the repo, accessible by all future subagents. This lets **implementer subagents** focus on implementation rather than exploration. + +3. Create a branch, and a draft PR. The PR should be marked as 'closing' the spec issue and tickets. + +4. Use **implementer subagents** to implement each ticket. Each implementer subagent should work in its own worktree, on its own branch. + +5. Once an **implementer subagent** completes, merge its work to the PR branch with a **merger subagent**. + +6. If this changes the **frontier** of available tickets, kick off more **implementer subagents** to work on the new tickets. This allows for maximum concurrency. + +7. Once all tickets are complete, run /code-review on the PR branch. Fix all issues raised by the code review in a single **implementer subagent**. + +8. Mark the PR as ready for review. + +9. Clean up all **implementer subagent** worktrees. diff --git a/.agents/skills/implement-spec/agents/openai.yaml b/.agents/skills/implement-spec/agents/openai.yaml new file mode 100644 index 00000000..043f27f4 --- /dev/null +++ b/.agents/skills/implement-spec/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Implement Spec" + short_description: "Implement a whole spec as one PR" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/implement/SKILL.md b/.agents/skills/implement/SKILL.md new file mode 100644 index 00000000..7a0b11f5 --- /dev/null +++ b/.agents/skills/implement/SKILL.md @@ -0,0 +1,15 @@ +--- +name: implement +description: "Implement a piece of work based on a spec or set of tickets." +disable-model-invocation: true +--- + +Implement the work described by the user in the spec or tickets. + +Use /tdd where possible, at pre-agreed seams. + +Run typechecking regularly, single test files regularly, and the full test suite once at the end. + +Once done, use /code-review to review the work. + +Commit your work to the current branch. diff --git a/.agents/skills/implement/agents/openai.yaml b/.agents/skills/implement/agents/openai.yaml new file mode 100644 index 00000000..f8794dc1 --- /dev/null +++ b/.agents/skills/implement/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Implement" + short_description: "Build work from a spec or tickets" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/improve-codebase-architecture/HTML-REPORT.md b/.agents/skills/improve-codebase-architecture/HTML-REPORT.md new file mode 100644 index 00000000..e39e8255 --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/HTML-REPORT.md @@ -0,0 +1,123 @@ +# HTML Report Format + +The architectural review is rendered as a single self-contained HTML file in the OS temp directory. Tailwind and Mermaid both come from CDNs. Mermaid handles graph-shaped diagrams reliably; hand-built divs and inline SVG handle the more editorial visuals (mass diagrams, cross-sections). Mix the two: don't lean on Mermaid for everything, it'll start to look generic. + +## Scaffold + +```html + + + + + Architecture review for {{repo name}} + + + + + +
+
...
+
...
+
...
+
+ + +``` + +## Header + +Repo name, date, and a compact legend: solid box = module, dashed line = seam, red arrow = leakage, thick dark box = deep module. No introduction paragraph. Straight into the candidates. + +## Candidate card + +The diagrams carry the weight. Prose is sparse, plain, and uses the glossary terms (from the `/codebase-design` skill) without ceremony. + +Each candidate is one `
`: + +- **Title**: short, names the deepening (e.g. "Collapse the Order intake pipeline"). +- **Badge row**: recommendation strength (`Strong` = emerald, `Worth exploring` = amber, `Speculative` = slate), plus a tag for the dependency category (`in-process`, `local-substitutable`, `ports & adapters`, `mock`). +- **Files**: monospaced list, `font-mono text-sm`. +- **Before / After diagram**: the centrepiece. Two columns, side by side. See patterns below. +- **Problem**: one sentence. What hurts. +- **Solution**: one sentence. What changes. +- **Wins**: bullets, ≤6 words each. e.g. "Tests hit one interface", "Pricing logic stops leaking", "Delete 4 shallow wrappers". +- **ADR callout** (if applicable): one line in an amber-tinted box. + +No paragraphs of explanation. If the diagram needs a paragraph to be understood, redraw the diagram. + +## Diagram patterns + +Pick the pattern that fits the candidate. Mix them. Don't make every diagram look the same. Variety is part of the point. + +### Mermaid graph (the workhorse for dependencies / call flow) + +Use a Mermaid `flowchart` or `graph` when the point is "X calls Y calls Z, and look at the mess." Wrap it in a Tailwind-styled card so it doesn't feel parachuted in. Style with classDef to colour leakage edges red and the deep module dark. Sequence diagrams work well for "before: 6 round-trips; after: 1." + +```html +
+
+    flowchart LR
+      A[OrderHandler] --> B[OrderValidator]
+      B --> C[OrderRepo]
+      C -.leak.-> D[PricingClient]
+      classDef leak stroke:#dc2626,stroke-width:2px;
+      class C,D leak
+  
+
+``` + +### Hand-built boxes-and-arrows (when Mermaid's layout fights you) + +Modules as `
`s with borders and labels. Arrows as inline SVG `` or `` elements positioned absolutely over a relative container. Reach for this when you want the "after" diagram to feel like one thick-bordered deep module with greyed-out internals, since Mermaid won't render that with the right weight. + +### Cross-section (good for layered shallowness) + +Stack horizontal bands (`h-12 border-l-4`) to show layers a call passes through. Before: 6 thin layers each doing nothing. After: 1 thick band labelled with the consolidated responsibility. + +### Mass diagram (good for "interface as wide as implementation") + +Two rectangles per module: one for interface surface area, one for implementation. Before: interface rectangle is nearly as tall as the implementation rectangle (shallow). After: interface rectangle is short, implementation rectangle is tall (deep). + +### Call-graph collapse + +Before: a tree of function calls rendered as nested boxes. After: the same tree collapsed into one box, with the now-internal calls shown faded inside it. + +## Style guidance + +- Lean editorial, not corporate-dashboard. Generous whitespace. Serif optional for headings (`font-serif` works well with stone/slate). +- Colour sparingly: one accent (emerald or indigo) plus red for leakage and amber for warnings. +- Keep diagrams ~320px tall so before/after sits comfortably side by side without scrolling. +- Use `text-xs uppercase tracking-wider` for module labels inside diagrams, so they read as schematic, not as UI. +- The only scripts are the Tailwind CDN and the Mermaid ESM import. The report is otherwise static: no app code, no interactivity beyond Mermaid's own rendering. + +## Top recommendation section + +One larger card. Candidate name, one sentence on why, anchor link to its card. That's it. + +## Tone + +Plain English, concise, but the architectural nouns and verbs come straight from the `/codebase-design` skill. Concision is not an excuse to drift. + +**Use exactly:** module, interface, implementation, depth, deep, shallow, seam, adapter, leverage, locality. + +**Never substitute:** component, service, unit (for module) · API, signature (for interface) · boundary (for seam) · layer, wrapper (for module, when you mean module). + +**Phrasings that fit the style:** + +- "Order intake module is shallow: interface nearly matches the implementation." +- "Pricing leaks across the seam." +- "Deepen: one interface, one place to test." +- "Two adapters justify the seam: HTTP in prod, in-memory in tests." + +**Wins bullets** name the gain in glossary terms: *"locality: bugs concentrate in one module"*, *"leverage: one interface, N call sites"*, *"interface shrinks; implementation absorbs the wrappers"*. Don't write *"easier to maintain"* or *"cleaner code"*, because those terms aren't in the glossary and don't earn their place. + +No hedging, no throat-clearing, no "it's worth noting that…". If a sentence could be a bullet, make it a bullet. If a bullet could be cut, cut it. If a term isn't in the `/codebase-design` glossary, reach for one that is before inventing a new one. diff --git a/.agents/skills/improve-codebase-architecture/SKILL.md b/.agents/skills/improve-codebase-architecture/SKILL.md new file mode 100644 index 00000000..a578dd0a --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/SKILL.md @@ -0,0 +1,71 @@ +--- +name: improve-codebase-architecture +description: Scan a codebase for deepening opportunities, present them as a visual HTML report, then grill through whichever one you pick. +disable-model-invocation: true +--- + +# Improve Codebase Architecture + +Surface architectural friction and propose **deepening opportunities**: refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. + +This command is _informed_ by the project's domain model and built on a shared design vocabulary: + +- Call the Skill tool with "codebase-design" for the architecture vocabulary (**module**, **interface**, **depth**, **seam**, **adapter**, **leverage**, **locality**) and its principles (the deletion test, "the interface is the test surface", "one adapter = hypothetical seam, two = real"). Use these terms exactly in every suggestion, and don't drift into "component," "service," "API," or "boundary." +- The domain language in `CONTEXT.md` gives names to good seams; ADRs in `docs/adr/` record decisions this command should not re-litigate. + +## Process + +### 1. Explore + +**Scope before you scan: YAGNI.** Deepening a module pays off by making future changes to it easier, so put extra weight on the parts of the codebase that have recently changed. Decide *where* to look before you look: + +- If the user named a direction (a module, a subsystem, a pain point), take it, and skip the inference below. +- Otherwise, walk back a good stretch of the commit history (`git log --oneline`) to find the codebase's hot spots, the files and areas that keep coming up, and let those paths pull your attention first. If the changes are scattered with no clear hot spot, widen the net. + +Read the project's domain glossary (`CONTEXT.md`) and any ADRs in the area you're touching first. + +Then spawn a sub-agent to walk the codebase. Don't follow rigid heuristics; explore organically and note where you experience friction: + +- Where does understanding one concept require bouncing between many small modules? +- Where are modules **shallow**, with an interface nearly as complex as the implementation? +- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? +- Where do tightly-coupled modules leak across their seams? +- Which parts of the codebase are untested, or hard to test through their current interface? + +Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. + +### 2. Present candidates as an HTML report + +Write a self-contained HTML file to the OS temp directory so nothing lands in the repo. Resolve the temp dir from `$TMPDIR`, falling back to `/tmp` (or `%TEMP%` on Windows), and write to `/architecture-review-.html` so each run gets a fresh file. Open it for the user (`xdg-open ` on Linux, `open ` on macOS, `start ` on Windows) and tell them the absolute path. + +The report uses **Tailwind via CDN** for layout and styling, and **Mermaid via CDN** for diagrams where a graph/flow/sequence reliably communicates the structure. Mix Mermaid with hand-crafted CSS/SVG visuals: use Mermaid when relationships are graph-shaped (call graphs, dependencies, sequences), and hand-built divs/SVG when you want something more editorial (mass diagrams, cross-sections, collapse animations). Each candidate gets a **before/after visualisation**. Be visual. + +For each candidate, render a card with: + +- **Files**: which files/modules are involved +- **Problem**: why the current architecture is causing friction +- **Solution**: plain English description of what would change +- **Benefits**: explained in terms of locality and leverage, and how tests would improve +- **Before / After diagram**: side-by-side, custom-drawn, illustrating the shallowness and the deepening +- **Recommendation strength**: one of `Strong`, `Worth exploring`, `Speculative`, rendered as a badge + +End the report with a **Top recommendation** section: which candidate you'd tackle first and why. + +**Use CONTEXT.md vocabulary for the domain, and the `/codebase-design` vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module," not "the FooBarHandler," and not "the Order service." + +**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly in the card (e.g. a warning callout: _"contradicts ADR-0007, but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. + +See [HTML-REPORT.md](HTML-REPORT.md) for the full HTML scaffold, diagram patterns, and styling guidance. + +Do NOT propose interfaces yet. After the file is written, ask the user: "Which of these would you like to explore?" + +### 3. Grilling loop + +Once the user picks a candidate, call the Skill tool with "grilling" to walk the decision tree with them: constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. + +Side effects happen inline as decisions crystallize; call the Skill tool with "domain-modeling" to keep the domain model current as you go: + +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md`. Create the file lazily if it doesn't exist. +- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing; skip ephemeral reasons ("not worth it right now") and self-evident ones. +- **Want to explore alternative interfaces for the deepened module?** Call the Skill tool with "codebase-design" and use its design-it-twice parallel sub-agent pattern. diff --git a/.agents/skills/improve-codebase-architecture/agents/openai.yaml b/.agents/skills/improve-codebase-architecture/agents/openai.yaml new file mode 100644 index 00000000..706fdca0 --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Improve Codebase Architecture" + short_description: "Find and grill architecture improvements" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/loop-me/SKILL.md b/.agents/skills/loop-me/SKILL.md new file mode 100644 index 00000000..e58a474c --- /dev/null +++ b/.agents/skills/loop-me/SKILL.md @@ -0,0 +1,32 @@ +--- +name: loop-me +description: Grill me about specs for the workflows I want to build, within this workspace. +disable-model-invocation: true +argument-hint: "A workflow to design, or nothing to go find one" +--- + +Run a stateful `/grilling` session whose only output is **workflow** specs. Use the grilling discipline (relentless, a round of questions at a time, a recommended answer attached to each) aimed at the vocabulary and goal below. Create, edit, and delete specs as the grilling resolves things. + +## The loop lens + +A **loop** is a recurring pattern in the user's life: their career, their week, their morning, a single repeated activity. Picturing a life as loops within loops reveals how predictable its activities really are, which is what makes them worth **delegating**. Use the lens to find loops worth specifying, and propose ones the user hasn't noticed. + +A **workflow** is the spec of one loop, made real. You run a workflow on a loop: the loop is its running instantiation. Workflows live in `workflows/*.md` and are the source of truth. + +## Vocabulary + +A shared language, reached for only when a workflow calls for it: never a checklist. **Mandate nothing structural**: a workflow needs no AI, no checkpoint, and no schedule unless the grilling shows it does. + +- **Trigger**: what fires each run, an **event** (a new email, a new issue) or a **schedule** (every morning). Event-triggering is usually the more efficient. +- **Checkpoint**: a human-in-the-loop point where the user is asked to verify or decide. Some workflows have none and run autonomously; some use no AI at all. +- **Push right**: defer the checkpoint as far as it will go. Do maximal work before involving the human, so they are asked once, late, with everything prepared. +- **Brief**: what a checkpoint presents, a tight, decision-ready summary (what was produced, why, and a link down to the asset itself), never the raw output. The user reads a brief, not a draft. Speed of review is imperative. + +## Definition of done + +A workflow spec is done when an implementer agent could build it without asking a single question. Grill until then; nothing is done while a question remains. + +## The workspace + +- `workflows/*.md`: one spec per workflow. +- `NOTES.md`: raw notes on the user's world, the tools they use, the channels they process, and their own terminology for both. When it is empty or thin, interview them about their world before specifying anything. Sharpen fuzzy terms into canonical ones as they surface, and record them here. diff --git a/.agents/skills/loop-me/agents/openai.yaml b/.agents/skills/loop-me/agents/openai.yaml new file mode 100644 index 00000000..1a4f4111 --- /dev/null +++ b/.agents/skills/loop-me/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Loop Me" + short_description: "Spec the workflows you want to build" +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/migrate-to-shoehorn/SKILL.md b/.agents/skills/migrate-to-shoehorn/SKILL.md new file mode 100644 index 00000000..ae4f965e --- /dev/null +++ b/.agents/skills/migrate-to-shoehorn/SKILL.md @@ -0,0 +1,118 @@ +--- +name: migrate-to-shoehorn +description: Migrate test files from `as` type assertions to @total-typescript/shoehorn. Use when user mentions shoehorn, wants to replace `as` in tests, or needs partial test data. +--- + +# Migrate to Shoehorn + +## Why shoehorn? + +`shoehorn` lets you pass partial data in tests while keeping TypeScript happy. It replaces `as` assertions with type-safe alternatives. + +**Test code only.** Never use shoehorn in production code. + +Problems with `as` in tests: + +- Trained not to use it +- Must manually specify target type +- Double-as (`as unknown as Type`) for intentionally wrong data + +## Install + +```bash +npm i @total-typescript/shoehorn +``` + +## Migration patterns + +### Large objects with few needed properties + +Before: + +```ts +type Request = { + body: { id: string }; + headers: Record; + cookies: Record; + // ...20 more properties +}; + +it("gets user by id", () => { + // Only care about body.id but must fake entire Request + getUser({ + body: { id: "123" }, + headers: {}, + cookies: {}, + // ...fake all 20 properties + }); +}); +``` + +After: + +```ts +import { fromPartial } from "@total-typescript/shoehorn"; + +it("gets user by id", () => { + getUser( + fromPartial({ + body: { id: "123" }, + }), + ); +}); +``` + +### `as Type` → `fromPartial()` + +Before: + +```ts +getUser({ body: { id: "123" } } as Request); +``` + +After: + +```ts +import { fromPartial } from "@total-typescript/shoehorn"; + +getUser(fromPartial({ body: { id: "123" } })); +``` + +### `as unknown as Type` → `fromAny()` + +Before: + +```ts +getUser({ body: { id: 123 } } as unknown as Request); // wrong type on purpose +``` + +After: + +```ts +import { fromAny } from "@total-typescript/shoehorn"; + +getUser(fromAny({ body: { id: 123 } })); +``` + +## When to use each + +| Function | Use case | +| --------------- | -------------------------------------------------- | +| `fromPartial()` | Pass partial data that still type-checks | +| `fromAny()` | Pass intentionally wrong data (keeps autocomplete) | +| `fromExact()` | Force full object (swap with fromPartial later) | + +## Workflow + +1. **Gather requirements** - ask user: + - What test files have `as` assertions causing problems? + - Are they dealing with large objects where only some properties matter? + - Do they need to pass intentionally wrong data for error testing? + +2. **Install and migrate**: + - [ ] Install: `npm i @total-typescript/shoehorn` + - [ ] Find test files with `as` assertions: `grep -r " as [A-Z]" --include="*.test.ts" --include="*.spec.ts"` + - [ ] Replace `as Type` with `fromPartial()` + - [ ] Replace `as unknown as Type` with `fromAny()` + - [ ] Add imports from `@total-typescript/shoehorn` + - [ ] Run type check to verify diff --git a/.agents/skills/migrate-to-shoehorn/agents/openai.yaml b/.agents/skills/migrate-to-shoehorn/agents/openai.yaml new file mode 100644 index 00000000..3bd79ee2 --- /dev/null +++ b/.agents/skills/migrate-to-shoehorn/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Migrate to Shoehorn" + short_description: "Replace test assertions with shoehorn" diff --git a/.agents/skills/prototype/LOGIC.md b/.agents/skills/prototype/LOGIC.md new file mode 100644 index 00000000..32be86a0 --- /dev/null +++ b/.agents/skills/prototype/LOGIC.md @@ -0,0 +1,67 @@ +# Logic Prototype + +A single, self-contained HTML file (a **shareable demo**) that lets anyone drive a state model by clicking buttons. Use this when the question is about **business logic, state transitions, or data shape**: the kind of thing that looks reasonable on paper but only feels wrong once you push it through real cases. + +Because it's one file with nothing to install, you can hand it to a non-developer (a designer, a PM, a domain expert) and let them feel the model for themselves. So it speaks their language, not the code's. + +## When this is the right shape + +- "I'm not sure if this state machine handles the edge case where X then Y." +- "Does this data model actually let me represent the case where..." +- "I want to feel out what the API should look like before writing it." +- Anything where someone wants to **press buttons and watch state change**. + +If the question is "what should this look like," this is the wrong branch. Use [UI.md](UI.md). + +## Process + +### 1. State the question + +Before writing code, write down what state model and what question you're prototyping. One paragraph, at the top of the demo (in a visible intro, not just a comment). A logic prototype that answers the wrong question is pure waste, so make the question explicit so it can be checked later, whether the user is watching now or returning to it AFK. + +### 2. Isolate the logic in a portable module + +Put the actual logic (the bit that's answering the question) in a single `"]' + ].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/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..ba72084b --- /dev/null +++ b/packages/description-renderer/test/seam.test.ts @@ -0,0 +1,359 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { 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("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"] +} 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..a529e44d --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,339 @@ +# 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. + +Mermaid diagrams are optional and validated before either tool writes a Tour. +The MCP server reuses the exact fence rules and locked Mermaid implementation +from `codetour-description-renderer`, so the Tour Generator and playback apply +the same contract. Validation is local and offline. + +## Requirements + +- Node.js >= 18 +- Git (only for `create_changes_tour`) + +## Démarrage rapide + +Depuis la racine du dépôt, installez d'abord le renderer partagé, puis les +dépendances du serveur MCP et vérifiez les deux packages : + +```bash +cd packages/description-renderer +npm install +npm run build +cd ../mcp-server +npm install +npm test +``` + +`npm test` compile le package et exécute toute la suite de tests. Vous pouvez +ensuite démarrer le serveur depuis un workspace : + +```bash +cd /path/to/workspace +node /path/to/codetour/packages/mcp-server/dist/src/cli.js +``` + +Le processus utilise MCP sur `stdio`. Il attend donc silencieusement les +requêtes d'un client MCP. Utilisez `Ctrl+C` pour l'arrêter lorsqu'il est lancé +manuellement. + +Le répertoire de travail du processus est le workspace. Une instance du serveur +traite exactement ce workspace et toutes les opérations y sont confinées : les chemins +réels sont résolus avant toute lecture ou écriture, les liens symboliques qui +sortent de la racine sont refusés et le serveur n'effectue aucun accès réseau. + +Le package expose le binaire `codetour-mcp`, disponible après `npm install` ou +`npm link`. Pour rendre ce binaire accessible globalement pendant le +développement local : + +```bash +npm link +cd /path/to/workspace +codetour-mcp +``` + +Configurez enfin votre client MCP avec la commande et son répertoire de travail, +comme dans l'exemple ci-dessous. Une fois connecté, le client découvre +automatiquement `create_project_tour` et `create_changes_tour`. + +## MCP client configuration + +```json +{ + "mcpServers": { + "codetour": { + "command": "node", + "args": ["/path/to/codetour/packages/mcp-server/dist/src/cli.js"], + "cwd": "/path/to/workspace" + } + } +} +``` + +The transport is `stdio` only. + +### Configuration dans Codex + +L'extension VS Code fournit les commandes `CodeTour: Configure MCP for Codex` +et `CodeTour: Repair MCP Configuration for Codex`. La première installe la +configuration globale lorsqu'elle manque ; la seconde remplace une +configuration obsolète, notamment après une mise à jour de l'extension. + +Pour un build de développement non installé en VSIX, l'équivalent manuel est : + +```bash +codex mcp add codetour -- \ + node /path/to/codetour/dist/mcp-server.js +``` + +Il n'est pas nécessaire de conserver un processus lancé manuellement : Codex +démarre le serveur `stdio` automatiquement. Vérifiez la configuration avec : + +```bash +codex mcp get codetour +``` + +Les nouvelles tâches Codex démarrent le serveur dans leur propre répertoire de +travail ; une seule configuration globale couvre donc tous les projets. + +Pour générer le tour général du projet, demandez par exemple : + +```text +Analyse ce dépôt, puis utilise l'outil MCP codetour.create_project_tour pour +générer un Project Tour. Présente le but du projet, ses points d'entrée, ses +composants importants et ses principaux flux d'exécution. Commence par une +étape ancrée sur le dossier racine pour en expliquer l'organisation, puis +présente les dossiers importants avant de détailler les fichiers. Utilise des +ancres stables dans les fichiers lorsque c'est possible. +``` + +Pour limiter le Project Tour à un sous-dossier, nommez-le explicitement dans la +demande. La première étape doit alors utiliser ce chemin dans son champ +`directory`, relativement à la racine du workspace. + +Le résultat est écrit dans `.tours/project.tour`. + +Pour documenter les changements de la branche courante : + +```text +Analyse les changements commités de cette branche depuis sa branche de base, +puis utilise codetour.create_changes_tour pour créer un Changes Tour. Détermine +la référence de base et utilise le SHA complet du HEAD actuel. N'inclus pas les +modifications non commitées. +``` + +Le résultat est écrit dans `.tours/changes.tour`. + +## 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. When the project has a +meaningful directory structure, it begins with a directory-anchored overview. +For a Project Tour scoped to a subdirectory, the first step anchors that exact +workspace-relative directory. Other important directories should be introduced +before their individual files. + +### `create_changes_tour` + +| Argument | Type | Required | Description | +| -------------------- | -------- | -------- | ------------------------------------------------------------- | +| `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). | + +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; use for structural overview steps and 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. + +### Mermaid diagrams + +Use Mermaid sparingly: include a diagram only when it materially clarifies a +relationship or flow. A diagram must use a bare fence with `mermaid` as its +info string. The nearest non-blank line before that fence must be a visible caption matching +`**Diagram — …**` (an em dash, with one or more descriptive characters). Blank +lines between the caption and fence are allowed; other Markdown content breaks +the caption association. + +The exact allowlist is `flowchart`, `sequenceDiagram`, `stateDiagram-v2`, +`classDiagram`, and `erDiagram`. Each individual description (the Tour +description and each step description are separate descriptions) accepts at +most three Mermaid fences, and each source is at most 20 KiB measured as UTF-8 +bytes. Mermaid syntax is parsed locally using the same locked version as +playback. A malformed caption, unsupported kind, oversized source, invalid +syntax, or fourth-and-later fence rejects the complete tool call. + +Diagram issues use paths such as +`steps[1].description.mermaid[0].source`; the path identifies the description, +fence index, and failing field. Every issue also reports the fence's starting +line and column. All descriptions are checked in one call, and no Tour file is +written when any diagram or ordinary Tour validation fails. + +### 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` | `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. | + +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 `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 +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 + +Install the package dependencies once: + +```bash +cd packages/description-renderer +npm install +npm run build +cd ../mcp-server +npm install +``` + +### Run all tests + +From `packages/mcp-server/`: + +```bash +npm test +``` + +The renderer must be built before the MCP package because the MCP server uses +its public shared-rule surface as a local package dependency. The MCP test +command then compiles the package and runs the complete Node.js test suite from +`dist/test/`. + +The same suite can be launched from the repository root without changing +directory: + +```bash +npm test --prefix packages/mcp-server +``` + +### Run the type checker or build only + +```bash +npm run typecheck # validate TypeScript without producing files +npm run build # compile sources and tests into dist/ +``` + +From the repository root, append `--prefix packages/mcp-server` to either +command. + +### Run one test file + +Individual tests run from the compiled `dist/test/` tree, so build the package +first. For example: + +```bash +npm run build +node --test dist/test/integration/changes-tour.test.js +node --test dist/test/integration/project-tour.test.js +node --test dist/test/integration/security.test.js +node --test dist/test/integration/cli.test.js +node --test dist/test/integration/packaged-binary.test.js +node --test dist/test/unit/validation.test.js +``` + +You can also filter tests in a file by name: + +```bash +node --test --test-name-pattern="STALE_HEAD" \ + dist/test/integration/changes-tour.test.js +``` + +### What the tests exercise + +- Project Tour and Changes Tour calls through the public MCP `stdio` seam; +- Tour Anchor, schema and V1 security validation; +- Git merge-base, stale `HEAD`, dirty-workspace and deletion-only scenarios; +- atomic replacement and preservation of an existing Tour after failures; +- CLI argument validation; +- `npm pack`, local installation of the archive, execution of the installed + `codetour-mcp` binary, and invocation of both public MCP tools. + +Integration tests create isolated temporary workspaces and Git repositories +and remove them after each scenario. The packaging smoke test stays local and +does not publish anything to npm. diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json new file mode 100644 index 00000000..3544f610 --- /dev/null +++ b/packages/mcp-server/package-lock.json @@ -0,0 +1,3212 @@ +{ + "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", + "@resvg/resvg-js": "2.6.2", + "ajv": "^6.12.6", + "codetour-description-renderer": "file:../description-renderer", + "jsdom": "26.1.0", + "mermaid": "11.12.2", + "zod": "^3.25.76" + }, + "bin": { + "codetour-mcp": "dist/src/cli.js" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "../description-renderer": { + "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/@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/@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/@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/@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" + ], + "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" + ], + "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" + ], + "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" + ], + "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/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/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/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/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/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/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/codetour-description-renderer": { + "resolved": "../description-renderer", + "link": true + }, + "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/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/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/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/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-dsv/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/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/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/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/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/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/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/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/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/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-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/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.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/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/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/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/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-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/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/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/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/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/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/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/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/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "license": "MIT" + }, + "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/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/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-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/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/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/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/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/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/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/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/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/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/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/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/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/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/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-encoding/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/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/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/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" + }, + "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..4ad03742 --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,50 @@ +{ + "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", + "dist/node_modules/codetour-description-renderer", + "dist/package.json", + "dist/schema.json", + "schema.json", + "README.md", + "LICENSE.txt" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsc -p tsconfig.json && node ../../scripts/stage-mcp-renderer.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepack": "node ../../scripts/stage-mcp-renderer.js", + "test": "npm run build && node ../../scripts/run-compiled-tests.js dist/test", + "start": "node dist/src/cli.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "@resvg/resvg-js": "2.6.2", + "ajv": "^6.12.6", + "codetour-description-renderer": "file:../description-renderer", + "jsdom": "26.1.0", + "mermaid": "11.12.2", + "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..c7559d37 --- /dev/null +++ b/packages/mcp-server/src/cli.ts @@ -0,0 +1,76 @@ +#!/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"; + +// Point d'entrée du binaire `codetour-mcp`. +// Le serveur ne traite qu'un seul workspace : le répertoire de travail du +// processus. Les clients MCP savent tous définir ce répertoire sans ajouter un +// argument propriétaire au protocole de lancement. + +interface ParsedArgs { + 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", + "", + "Options:", + " --help, -h Show this help", + " --version, -v Show the version", + ].join("\n"); +} + +// Analyse les arguments de la ligne de commande. +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 { + 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; + } + const workspaceRoot = process.cwd(); + // Vérifie le répertoire de travail avant de démarrer, pour échouer clairement. + try { + createContext(workspaceRoot); + } catch { + console.error( + `Error: the working directory is not an accessible workspace: ${workspaceRoot}` + ); + process.exit(1); + } + + const server = createServer(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..be92b466 --- /dev/null +++ b/packages/mcp-server/src/codetour-schema.ts @@ -0,0 +1,11 @@ +import Ajv from "ajv"; +import draft04MetaSchema from "ajv/lib/refs/json-schema-draft-04.json"; +import codetourSchema from "../schema.json"; + +// Validation de la sortie contre le schéma CodeTour général (draft-04) : le +// serveur applique son schéma d'entrée plus strict, puis garantit que le +// fichier produit reste compatible avec le consommateur existant. +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..3d22b772 --- /dev/null +++ b/packages/mcp-server/src/context.ts @@ -0,0 +1,12 @@ +import * as fs from "fs"; +import * as path from "path"; +import { WorkspaceContext } from "./types"; + +// Contexte du workspace : la racine telle que fournie, plus sa résolution en +// chemin réel. Toute vérification de confinement compare les chemins réels, +// afin qu'un lien symbolique ne puisse pas sortir de la racine configurée. +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..d86a897e --- /dev/null +++ b/packages/mcp-server/src/git.ts @@ -0,0 +1,175 @@ +import { execFile } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import { WorkspaceContext } from "./types"; + +// Toutes les opérations Git du serveur passent par `git` en ligne de commande, +// exécuté dans le workspace configuré. Le Changes Tour dépend de cet +// historique : merge-base, vérification du SHA de tête, liste des fichiers +// modifiés et détection des changements non committés. + +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, "/"); +} + +// Changements staged, unstaged et untracked du workspace, à l'exception des +// deux fichiers de Tour réservés : une génération précédente ne doit pas +// provoquer son propre avertissement de workspace sale. +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; +} + +// Un répertoire `.tours` non suivi apparaît sous la forme d'une seule entrée +// `?? .tours/` en mode « normal » : on l'ignore uniquement si son contenu se +// limite aux fichiers de Tour réservés. +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/mermaid-validation.ts b/packages/mcp-server/src/mermaid-validation.ts new file mode 100644 index 00000000..7ed4ef5b --- /dev/null +++ b/packages/mcp-server/src/mermaid-validation.ts @@ -0,0 +1,156 @@ +import { + ALLOWED_DIAGRAM_KINDS, + MAX_DIAGRAMS_PER_DESCRIPTION, + MAX_DIAGRAM_SOURCE_BYTES, + diagramKindOf, + evaluateDiagramFence, + findDiagramFences, + matchDiagramCaption, + renderMermaidDiagram, +} from "codetour-description-renderer"; +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. ` + + "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(", ")}. ` + + `Each description accepts at most ${MAX_DIAGRAMS_PER_DESCRIPTION} Mermaid fences, each source is limited to ` + + `${MAX_DIAGRAM_SOURCE_BYTES / 1024} KB, and every source must have valid Mermaid syntax. ` + + `Validation is offline, reports all diagram errors with their description, fence, and source location, ` + + `and preserves the previous Tour when any error is found.`; + +export { MERMAID_TOOL_GUIDANCE }; + +async function parseMermaid(source: string): Promise { + // renderMermaidDiagram uses the exact locked Mermaid engine and the same + // strict configuration as playback. Its PNG is intentionally discarded: + // this call is the offline syntax-validation seam for the MCP server. + await renderMermaidDiagram(source, "light"); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function lineAndColumnOf(description: string, offset: number): string { + const beforeFence = description.slice(0, offset); + const lastLineBreak = beforeFence.lastIndexOf("\n"); + const line = beforeFence.split("\n").length; + const column = offset - lastLineBreak; + return `line ${line}, column ${column}`; +} + +function fencePath(descriptionPath: string, index: number, field?: string): string { + const path = `${descriptionPath}.mermaid[${index}]`; + return field ? `${path}.${field}` : path; +} + +function canonicalCaption(caption: string | undefined): string | undefined { + // findDiagramFences already applies the shared caption parser. Running the + // captured value through that same public function keeps this boundary tied + // to the renderer's caption grammar rather than reimplementing it here. + return caption === undefined + ? undefined + : matchDiagramCaption(`**${caption}**`); +} + +async function validateDescription( + description: string, + descriptionPath: string +): Promise { + const issues: Issue[] = []; + const fences = findDiagramFences(description); + + for (let index = 0; index < fences.length; index++) { + const fence = fences[index]; + const location = lineAndColumnOf(description, fence.start); + if (index >= MAX_DIAGRAMS_PER_DESCRIPTION) { + issues.push({ + path: fencePath(descriptionPath, index), + message: + `the Mermaid fence at ${location} exceeds the limit of ${MAX_DIAGRAMS_PER_DESCRIPTION} ` + + "fences per description", + }); + continue; + } + + if (!fence.closed) { + issues.push({ + path: fencePath(descriptionPath, index, "source"), + message: `the Mermaid fence at ${location} is not closed` + }); + continue; + } + + const evaluation = evaluateDiagramFence({ + caption: canonicalCaption(fence.caption), + source: fence.source, + }); + if (!evaluation.allowed) { + const field = + evaluation.reason === "caption" + ? "caption" + : evaluation.reason === "kind" + ? "kind" + : "source"; + let message: string; + if (evaluation.reason === "caption") { + message = + `the Mermaid fence at ${location} requires a nearest non-blank caption matching ` + + "**Diagram — …**"; + } else if (evaluation.reason === "size") { + message = + `the Mermaid source at ${location} is ${Buffer.byteLength(fence.source, "utf8")} bytes; ` + + `the limit is ${MAX_DIAGRAM_SOURCE_BYTES} bytes`; + } else { + const detectedKind = diagramKindOf(fence.source); + message = + `the Mermaid source at ${location} uses unsupported kind ` + + `${detectedKind ? `"${detectedKind}"` : "(none detected)"}; ` + + `supported kinds are ${ALLOWED_DIAGRAM_KINDS.join(", ")}`; + } + issues.push({ path: fencePath(descriptionPath, index, field), message }); + continue; + } + + try { + await parseMermaid(fence.source); + } catch { + issues.push({ + path: fencePath(descriptionPath, index, "source"), + message: `the Mermaid source at ${location} has invalid Mermaid syntax`, + }); + } + } + + return issues; +} + +export async function validateMermaidDescriptions(raw: unknown): Promise { + if (!isPlainObject(raw)) { + return []; + } + + const descriptions: Array<{ value: string; path: string }> = []; + if (typeof raw.description === "string") { + descriptions.push({ value: raw.description, path: "description" }); + } + if (Array.isArray(raw.steps)) { + for (let index = 0; index < raw.steps.length; index++) { + const step = raw.steps[index]; + if (isPlainObject(step) && typeof step.description === "string") { + descriptions.push({ + value: step.description, + path: `steps[${index}].description`, + }); + } + } + } + + const issues: Issue[] = []; + for (const description of descriptions) { + issues.push(...(await validateDescription(description.value, description.path))); + } + return issues; +} diff --git a/packages/mcp-server/src/persistence.ts b/packages/mcp-server/src/persistence.ts new file mode 100644 index 00000000..660ab311 --- /dev/null +++ b/packages/mcp-server/src/persistence.ts @@ -0,0 +1,54 @@ +import * as fs from "fs"; +import * as path from "path"; +import { WorkspaceContext } from "./types"; + +// Écriture atomique des Tours générés : le contenu est d'abord écrit dans un +// fichier temporaire du même répertoire, synchronisé, puis renommé sur la +// destination. Un échec ne laisse jamais de fichier partiellement écrit et +// préserve la version précédente. Le répertoire de sortie doit rester confiné +// au workspace (chemin réel résolu avant l'écriture). + +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..2839f043 --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,543 @@ +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 { + MERMAID_TOOL_GUIDANCE, + validateMermaidDescriptions, +} from "./mermaid-validation"; +import packageJson from "../package.json"; + +// Point de passage entre l'agent IA et CodeTour : l'agent propose une visite +// complète, puis le serveur garantit qu'elle peut être ouverte sans risque dans +// le projet. Deux usages sont proposés : découvrir le projet dans son ensemble +// ou expliquer les changements de la branche courante. +// +// Les schémas d'entrée des outils restent volontairement permissifs +// (`z.unknown()` + `.passthrough()`) : le SDK MCP rejette lui-même les arguments +// qui ne correspondent pas à un schéma strict, ce qui casserait l'exigence +// d'agréger toutes les erreurs de validation dans une seule réponse. La +// validation complète, champ par champ, est donc effectuée dans validation.ts. + +const CODETOUR_SCHEMA_URI = "https://aka.ms/codetour-schema"; +const SERVER_NAME = "codetour-mcp"; +const SERVER_VERSION = packageJson.version; + +// Chaque type de visite possède une destination stable. L'utilisateur retrouve +// ainsi toujours la dernière visite générée au même endroit, sans que ces +// fichiers soient eux-mêmes considérés comme des changements à expliquer. +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. Begin with a directory-anchored overview step whenever " + + "the project has a meaningful directory structure. If the tour is scoped to a subdirectory, anchor " + + "that first step to the exact workspace-relative directory. Use additional directory-anchored steps " + + "to introduce major components before moving into detailed file anchors. " + + "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. " + + MERMAID_TOOL_GUIDANCE; + +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: 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 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. " + + MERMAID_TOOL_GUIDANCE; + +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(), + baseRef: z.unknown().optional(), + headRef: z.unknown().optional(), + includeUncommittedChanges: 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; +} + +// Produit la visite d'accueil du projet. Elle reste disponible quelle que soit +// la branche ouverte, car elle présente le fonctionnement global du code et non +// un instant particulier de son historique Git. +async function handleCreateProjectTour( + ctx: WorkspaceContext, + args: unknown +): Promise { + const rawSteps = extractSteps(args); + // La validation agrège toutes les erreurs (paramètres puis étapes) avant de + // répondre, pour permettre à l'agent de corriger la proposition en un cycle. + const { params, issues: paramIssues } = validateProjectParams(args); + const mermaidIssues = await validateMermaidDescriptions(args); + if (rawSteps === undefined || (Array.isArray(rawSteps) && rawSteps.length === 0)) { + if (mermaidIssues.length === 0) { + return errorResponse("TOUR_STEPS_REQUIRED", "A tour requires at least one step."); + } + + return errorResponse( + "INVALID_PROPOSAL", + "The create_project_tour arguments are invalid.", + [ + ...mermaidIssues, + { path: "steps", message: "is required and must contain at least one step" } + ] + ); + } + + const allIssues = [...paramIssues, ...mermaidIssues]; + 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).` + ); +} + +// Produit une visite de revue de branche. Le lecteur voit ce qui a changé +// depuis la branche de référence ; si le code avance pendant la génération, la +// visite est refusée pour ne jamais présenter une explication déjà obsolète. +// Le travail local n'est inclus que lorsque l'appelant le demande explicitement. +async function handleCreateChangesTour( + ctx: WorkspaceContext, + args: unknown +): Promise { + const rawSteps = extractSteps(args); + // Même stratégie d'agrégation que pour le Project Tour : toutes les erreurs + // de validation sont collectées avant de répondre. + const { params, issues: paramIssues } = validateChangesParams(args); + const mermaidIssues = await validateMermaidDescriptions(args); + if (rawSteps === undefined || (Array.isArray(rawSteps) && rawSteps.length === 0)) { + if (mermaidIssues.length === 0) { + return errorResponse("TOUR_STEPS_REQUIRED", "A tour requires at least one step."); + } + + return errorResponse( + "INVALID_PROPOSAL", + "The create_changes_tour arguments are invalid.", + [ + ...mermaidIssues, + { path: "steps", message: "is required and must contain at least one step" } + ] + ); + } + + const allIssues = [...paramIssues, ...mermaidIssues]; + 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 baseRef = params.baseRef as string; + const headRef = params.headRef as string; + const includeUncommittedChanges = params.includeUncommittedChanges === true; + + // Le Changes Tour dépend de l'historique Git : hors dépôt, l'outil échoue. + if (!(await isGitRepository(ctx))) { + return errorResponse( + "GIT_REPOSITORY_REQUIRED", + "create_changes_tour requires a Git repository, but the workspace root is not inside one." + ); + } + + // Le SHA analysé doit correspondre exactement au HEAD courant : sinon + // l'explication risquerait de ne pas correspondre au snapshot relu. + 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 (headRef !== currentHead) { + return errorResponse( + "STALE_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, baseRef, headRef); + } catch (error) { + return errorResponse( + "INVALID_BASE_REF", + `Unable to compute the merge-base between ${baseRef} and ${headRef}: ${(error as Error).message}` + ); + } + + // Sans aucun changement committé (et sans travail local inclus), l'outil + // répond NO_CHANGES et conserve l'ancien Changes Tour : une visite vide ne + // remplace jamais une visite utile. + const uncommitted = await uncommittedChanges(ctx); + if ( + (await committedDiffIsEmpty(ctx, mergeBaseSha, headRef)) && + (!includeUncommittedChanges || uncommitted.length === 0) + ) { + return errorResponse( + "NO_CHANGES", + `No committed changes between the merge-base of ${baseRef} (${mergeBaseSha}) and ${headRef}. The previous tour file was preserved.` + ); + } + + // Les changements non committés sont exclus par défaut (avertissement), + // ou inclus explicitement : le Tour n'a alors pas de `ref` et signale que + // le résultat décrit un état local non reproductible. + const warnings: Warning[] = []; + if (includeUncommittedChanges) { + 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); + + // Ensemble des fichiers modifiés (committés, plus les fichiers non committés + // lorsque includeUncommittedChanges est actif), utilisé pour l'avertissement + // NO_CHANGED_FILE_ANCHOR. + const changedInWorkspace = await changedFilesInWorkspace(ctx, mergeBaseSha, headRef); + if (includeUncommittedChanges) { + 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, headRef)); + // La description est enrichie automatiquement avec la provenance, la base et + // la tête, afin que le périmètre analysé soit toujours connu du lecteur. + 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}` + : provenance; + + const tour: TourFile = { + $schema: CODETOUR_SCHEMA_URI, + title, + description, + ...(!includeUncommittedChanges ? { ref: headRef } : {}), + steps: finalSteps, + }; + + // HEAD peut avancer pendant les lectures Git effectuées ci-dessus. Le relire + // au dernier moment empêche une analyse devenue obsolète de remplacer le + // Changes Tour précédent. + 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; + } + + return successResponse( + CHANGES_TOUR_PATH, + finalSteps.length, + warnings, + `Created Changes Tour at ${CHANGES_TOUR_PATH} with ${finalSteps.length} step(s) for head ${headRef} (base ${baseRef}).` + ); +} + +// Avertissement non bloquant lorsqu'un Tour dépasse quinze étapes. +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}.`, + }, + ]; +} + +// Valide le Tour produit contre le schéma CodeTour général, puis l'écrit de +// façon atomique. En cas d'échec, l'ancien fichier reste intact. +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; +} + +// Fichiers modifiés par le diff committé, convertis en chemins relatifs au +// workspace (le workspace peut être un sous-répertoire du dépôt). +async function changedFilesInWorkspace( + ctx: WorkspaceContext, + mergeBaseSha: string, + headRef: string +): Promise { + const files = await changedFiles(ctx, mergeBaseSha, headRef); + const prefix = normalizeSlashes(await workspacePrefix(ctx)); + return files + .map(normalizeSlashes) + .filter((file) => file.startsWith(prefix)) + .map((file) => file.slice(prefix.length)); +} + +// Titre par défaut du Changes Tour : « Changes on », avec un +// repli sur le SHA court lorsque HEAD est détaché. +async function defaultChangesTitle( + ctx: WorkspaceContext, + headRef: string +): Promise { + const branch = await currentBranchName(ctx); + if (branch && branch !== "HEAD") { + return `Changes on ${branch}`; + } + return `Changes at ${headRef.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..c66367a6 --- /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 { + baseRef?: string; + headRef?: string; + includeUncommittedChanges?: 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..63b4f425 --- /dev/null +++ b/packages/mcp-server/src/validation.ts @@ -0,0 +1,498 @@ +import * as fs from "fs"; +import * as path from "path"; +import { + ChangesParams, + Issue, + Position, + ProjectParams, + Selection, + TourStep, + WorkspaceContext, +} from "./types"; + +// Moteur de validation déterministe des propositions de Tours. +// +// Toute erreur détectée est agrégée dans une liste d'`Issue` (chemin + message) +// avant de répondre, afin que l'agent corrige la proposition en un seul cycle. +// Une étape peut être purement explicative (sans Tour Anchor) ; lorsqu'elle +// possède un localisateur, celui-ci est validé contre l'état réel du workspace +// (existence, bornes de lignes/sélections, unicité du motif, confinement des +// liens symboliques à la racine configurée). + +export const STEP_FIELDS = [ + "title", + "description", + "file", + "directory", + "line", + "pattern", + "selection", +] as const; + +export const MAX_RECOMMENDED_STEPS = 15; + +// Schémas d'URI actifs refusés dans le Markdown : une description générée ne +// doit pas pouvoir déclencher une action dans l'éditeur ou le terminal. +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", + "baseRef", + "headRef", + "includeUncommittedChanges", +] 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 }; + } + // Le schéma d'entrée V1 est strict : tout champ inconnu est refusé, ce qui + // exclut notamment `when`, `commands`, `uri` et les capacités de V2. + 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.baseRef !== "string" || raw.baseRef.trim() === "") { + issues.push({ path: "baseRef", message: "is required and must be a non-empty string" }); + } else { + params.baseRef = raw.baseRef; + } + 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.headRef = raw.headRef; + } + if (raw.includeUncommittedChanges !== undefined) { + if (typeof raw.includeUncommittedChanges !== "boolean") { + issues.push({ path: "includeUncommittedChanges", message: "must be a boolean" }); + } else { + params.includeUncommittedChanges = raw.includeUncommittedChanges; + } + } + return { params, issues }; +} + +// Champs communs aux deux outils : titre optionnel, description optionnelle +// (avec filtrage des schémas d'URI actifs) et liste d'étapes obligatoire. +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: [] }; +} + +// Valide chaque étape contre les règles de Tour Anchor : +// - au plus un localisateur principal (fichier ou répertoire) ; +// - ligne, motif et sélection ne sont valides qu'avec un fichier ; +// - ligne et motif sont mutuellement exclusifs ; +// - le motif doit identifier une occurrence unique dans le fichier ; +// - les bornes de ligne et de sélection sont vérifiées contre le contenu réel. +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" }); + } + } + + // Le Markdown d'une étape ne peut contenir que des liens et images HTTPS + // ordinaires ; les schémas actifs sont refusés. + 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:)", + }); + } + + // Résolution du chemin réel de l'ancre : une lecture n'a lieu qu'après + // vérification du confinement au workspace. + 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; +} + +// Vérifie qu'une ancre (fichier ou répertoire) existe réellement et reste +// confinée au workspace : le chemin réel est résolu avant toute lecture, et un +// lien symbolique qui sort de la racine configurée est refusé. +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/mermaid-fixtures.ts b/packages/mcp-server/test/helpers/mermaid-fixtures.ts new file mode 100644 index 00000000..f2d78860 --- /dev/null +++ b/packages/mcp-server/test/helpers/mermaid-fixtures.ts @@ -0,0 +1,23 @@ +export const ALLOWED_DIAGRAM_SOURCES: Record = { + flowchart: "flowchart TD\n A --> B", + sequenceDiagram: "sequenceDiagram\n A->>B: Hello", + "stateDiagram-v2": "stateDiagram-v2\n [*] --> Idle", + classDiagram: "classDiagram\n class Account {\n +String id\n }", + erDiagram: "erDiagram\n CUSTOMER ||--o{ ORDER : places", +}; + +export function captionedDiagram(caption: string, source: string): string { + return [`**Diagram — ${caption}**`, "", "```mermaid", source, "```"].join( + "\n" + ); +} + +export function oversizedFlowchartSource(): string { + const prefix = "flowchart TD\n A --> B\n%%"; + return prefix + "x".repeat(20 * 1024 + 1 - Buffer.byteLength(prefix, "utf8")); +} + +export const INVALID_FLOWCHART_SOURCE = [ + "flowchart TD", + " this is not mermaid at all (((", +].join("\n"); 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..da1ee1cb --- /dev/null +++ b/packages/mcp-server/test/helpers/test-utils.ts @@ -0,0 +1,160 @@ +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, + envOverrides: Record = {} +): 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], + cwd: workspaceRoot, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ), + ...envOverrides, + }, + }); + await client.connect(transport); + return client; +} + +export async function withServer( + root: string, + run: (client: Client) => Promise, + envOverrides: Record = {} +): Promise { + const client = await startServer(root, envOverrides); + 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..88623434 --- /dev/null +++ b/packages/mcp-server/test/integration/changes-tour.test.ts @@ -0,0 +1,684 @@ +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, + git, + headSha, + initGitRepo, + readTourFile, + rmrf, + structuredCode, + tempDir, + tourFileValidAgainstSchema, + warningCodes, + withServer, + writeFile, +} from "../helpers/test-utils"; +import { + ALLOWED_DIAGRAM_SOURCES, + INVALID_FLOWCHART_SOURCE, + captionedDiagram, +} from "../helpers/mermaid-fixtures"; + +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("accepts all five allowed Mermaid kinds in a Changes Tour", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: Object.entries(ALLOWED_DIAGRAM_SOURCES).map(([kind, source]) => ({ + description: captionedDiagram(`${kind} change`, source), + })), + }); + assert.equal(response.isError, false, response.text); + assert.equal(response.structured.stepCount, 5); + }); + assert.ok(tourFileValidAgainstSchema(root, ".tours/changes.tour")); + } finally { + rmrf(root); + } +}); + +test("preserves the previous Changes Tour when Mermaid syntax is invalid", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const first = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "Original.", file: "feature.txt" }], + }); + assert.equal(first.isError, false); + const before = readTourFile(root, ".tours/changes.tour"); + + const second = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [ + { + description: captionedDiagram("Broken", INVALID_FLOWCHART_SOURCE), + }, + ], + }); + assert.equal(second.isError, true); + assert.equal(structuredCode(second), "INVALID_PROPOSAL"); + assert.deepEqual(readTourFile(root, ".tours/changes.tour"), before); + }); + } finally { + rmrf(root); + } +}); + +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", { + baseRef: baseSha, + headRef: 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", { + baseRef: baseSha, + headRef: 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", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "d" }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "STALE_HEAD"); + }); + } finally { + rmrf(root); + } +}); + +test( + "preserves the previous tour when HEAD moves immediately before persistence", + { + skip: + process.platform === "win32" && + "the deterministic Git interception uses POSIX process semantics", + }, + 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 gitExecutable = process.platform === "win32" ? "git.exe" : "git"; + const realGit = (process.env.PATH ?? "") + .split(path.delimiter) + .map((directory) => path.join(directory, gitExecutable)) + .find((candidate) => fs.existsSync(candidate)); + assert.ok(realGit, "git must be available on PATH"); + const gitShim = path.join( + shimDir, + process.platform === "win32" ? "git.cmd" : "git" + ); + writeFile( + shimDir, + path.basename(gitShim), + process.platform === "win32" + ? `@echo off +if not "%*" == "rev-parse --abbrev-ref HEAD" goto run +type nul > "${reachedFinalCheck}" +:wait +if exist "${continueRequest}" goto run +ping -n 1 -w 10 127.0.0.1 > nul +goto wait +:run +"${realGit}" %* +` + : `#!/bin/sh +if [ "$*" = "rev-parse --abbrev-ref HEAD" ]; then + : > "${reachedFinalCheck}" + while [ ! -e "${continueRequest}" ]; do sleep 0.01; done +fi +exec "${realGit}" "$@" +` + ); + if (process.platform !== "win32") { + 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", { + baseRef: baseSha, + headRef: 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", { + baseRef: "main", + headRef: "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", { + baseRef: head, + headRef: 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", { + baseRef: "no-such-branch", + headRef: 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", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "Original.", file: "feature.txt" }], + }); + assert.equal(first.isError, false); + const second = await callTool(client, "create_changes_tour", { + baseRef: head, + headRef: 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", { + baseRef: baseSha, + headRef: 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", { + baseRef: baseSha, + headRef: 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", { + baseRef: baseSha, + headRef: head, + includeUncommittedChanges: 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", { + baseRef: baseSha, + headRef: 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", { + baseRef: baseSha, + headRef: 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", { + baseRef: baseSha, + headRef: 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", { + baseRef: baseSha, + headRef: 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", { + baseRef: baseSha, + headRef: 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", { + baseRef: baseSha, + headRef: 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", { + baseRef: baseSha, + headRef: 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", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "d", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_EXCLUDED")); + }); + } finally { + rmrf(root); + } +}); + +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", { + baseRef: head, + headRef: head, + includeUncommittedChanges: 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", { + baseRef: baseSha, + headRef: head, + includeUncommittedChanges: 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", + { + skip: + process.platform === "win32" && + "POSIX directory permissions are unavailable", + }, + async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const first = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: 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", { + baseRef: baseSha, + headRef: 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/cli.test.ts b/packages/mcp-server/test/integration/cli.test.ts new file mode 100644 index 00000000..5ea8f7c5 --- /dev/null +++ b/packages/mcp-server/test/integration/cli.test.ts @@ -0,0 +1,20 @@ +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("uses the process working directory and rejects the removed workspace argument", () => { + const root = tempDir(); + try { + const result = spawnSync( + process.execPath, + [CLI_PATH, "--workspace-root", root], + { cwd: root, encoding: "utf8", timeout: 5_000 } + ); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Unknown argument: --workspace-root/); + } 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..bbd30640 --- /dev/null +++ b/packages/mcp-server/test/integration/packaged-binary.test.ts @@ -0,0 +1,179 @@ +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, then pass + // every registry dependency exposed by the packed manifest as a local + // source. npm still resolves direct dependencies during an install even + // when their directories already exist; without the local sources, a cold + // platform-specific cache (notably macOS ARM) fails in --offline mode. + 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", "@resvg", "resvg-js"), + path.join(packageRoot, "node_modules", "jsdom"), + path.join(packageRoot, "node_modules", "mermaid"), + path.join(packageRoot, "node_modules", "zod"), + ], + sandbox, + npmCache + ); + + // The shared renderer is a local monorepo package, so npm pack cannot + // carry its sibling path dependency into the isolated consumer. Seed the + // consumer with the already-built package and its already-installed + // dependency tree, preserving the test's fully offline contract. + const rendererRoot = path.join(packageRoot, "..", "description-renderer"); + const rendererTarget = path.join( + installationRoot, + "node_modules", + "codetour-description-renderer" + ); + let rendererIsAlreadyLinked = false; + try { + rendererIsAlreadyLinked = fs.lstatSync(rendererTarget).isSymbolicLink(); + } catch { + // The package installer may not have created the local dependency. + } + if (rendererIsAlreadyLinked) { + fs.unlinkSync(rendererTarget); + } + if (!rendererIsAlreadyLinked || !fs.existsSync(rendererTarget)) { + fs.cpSync(rendererRoot, rendererTarget, { + recursive: true, + filter: (source) => + path.basename(source) !== "node_modules" && + !source.includes(`${path.sep}node_modules${path.sep}`), + }); + fs.cpSync( + path.join(rendererRoot, "node_modules"), + path.join(installationRoot, "node_modules"), + { recursive: true } + ); + } + + 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, + cwd: 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/integration/project-tour.test.ts b/packages/mcp-server/test/integration/project-tour.test.ts new file mode 100644 index 00000000..f5be7714 --- /dev/null +++ b/packages/mcp-server/test/integration/project-tour.test.ts @@ -0,0 +1,554 @@ +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, + issuePaths, + readTourFile, + rmrf, + structuredCode, + structuredIssues, + tempDir, + tourFileValidAgainstSchema, + warningCodes, + withServer, + writeFile, +} from "../helpers/test-utils"; +import { + ALLOWED_DIAGRAM_SOURCES, + INVALID_FLOWCHART_SOURCE, + captionedDiagram, + oversizedFlowchartSource, +} from "../helpers/mermaid-fixtures"; + +test("accepts all five allowed Mermaid kinds in a Project Tour", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: Object.entries(ALLOWED_DIAGRAM_SOURCES).map(([kind, source]) => ({ + description: captionedDiagram(`${kind} overview`, source), + })), + }); + assert.equal(response.isError, false, response.text); + assert.equal(response.structured.stepCount, 5); + }); + const tour = readTourFile(root, ".tours/project.tour"); + assert.equal((tour.steps as unknown[]).length, 5); + assert.ok(tourFileValidAgainstSchema(root, ".tours/project.tour")); + } finally { + rmrf(root); + } +}); + +test("aggregates Mermaid errors with fence paths and source locations", async () => { + const root = tempDir(); + try { + const description = [ + ["Introductory text", "", "```mermaid", ALLOWED_DIAGRAM_SOURCES.flowchart, "```"].join("\n"), + captionedDiagram("Unsupported", "pie title Pets\n \"Dogs\" : 1"), + captionedDiagram("Oversized", oversizedFlowchartSource()), + captionedDiagram("Fourth", ALLOWED_DIAGRAM_SOURCES.flowchart), + ].join("\n\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + description, + steps: [ + { + description: captionedDiagram("Broken syntax", INVALID_FLOWCHART_SOURCE), + }, + { + description: [ + "**Diagram – malformed caption**", + "", + "```mermaid", + ALLOWED_DIAGRAM_SOURCES.flowchart, + "```", + ].join("\n"), + }, + ], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + const issues = structuredIssues(response); + assert.deepEqual( + issues.map((issue) => issue.path), + [ + "description.mermaid[0].caption", + "description.mermaid[1].kind", + "description.mermaid[2].source", + "description.mermaid[3]", + "steps[0].description.mermaid[0].source", + "steps[1].description.mermaid[0].caption", + ] + ); + assert.ok(issues.every((issue) => issue.message.includes("line"))); + }); + assert.equal(fs.existsSync(path.join(root, ".tours/project.tour")), false); + } finally { + rmrf(root); + } +}); + +test("preserves the previous Project Tour when Mermaid validation fails", 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 before = readTourFile(root, ".tours/project.tour"); + + const second = await callTool(client, "create_project_tour", { + title: "Broken", + steps: [ + { + description: captionedDiagram("Broken", INVALID_FLOWCHART_SOURCE), + }, + ], + }); + assert.equal(second.isError, true); + assert.equal(structuredCode(second), "INVALID_PROPOSAL"); + assert.deepEqual(readTourFile(root, ".tours/project.tour"), before); + }); + } finally { + rmrf(root); + } +}); + +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 top-level Mermaid errors even when steps are missing", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + description: captionedDiagram("Broken", INVALID_FLOWCHART_SOURCE), + steps: [], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + assert.ok(issuePaths(response).includes("description.mermaid[0].source")); + assert.ok(issuePaths(response).includes("steps")); + }); + } finally { + rmrf(root); + } +}); + +test("rejects an unterminated Mermaid fence", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + description: [ + "**Diagram — Unterminated**", + "", + "```mermaid", + ALLOWED_DIAGRAM_SOURCES.flowchart, + ].join("\n"), + steps: [{ description: "A valid step." }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + assert.ok(issuePaths(response).includes("description.mermaid[0].source")); + }); + } 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..903bde94 --- /dev/null +++ b/packages/mcp-server/test/integration/security.test.ts @@ -0,0 +1,122 @@ +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")); + 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("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("**Diagram — …**")); + }); + } 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..ed3c0015 --- /dev/null +++ b/packages/mcp-server/test/unit/validation.test.ts @@ -0,0 +1,192 @@ +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 baseRef and a full headRef SHA", () => { + const { issues } = validateChangesParams({ steps: [] }); + const paths = issues.map((issue) => issue.path); + 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({ + baseRef: "main", + headRef: "deadbeef", + steps: [], + }); + assert.ok(issues.some((issue) => issue.path === "headRef")); +}); + +test("validateChangesParams rejects a non-boolean includeUncommittedChanges", () => { + const { issues } = validateChangesParams({ + baseRef: "main", + headRef: "a".repeat(40), + includeUncommittedChanges: "yes", + steps: [], + }); + assert.ok(issues.some((issue) => issue.path === "includeUncommittedChanges")); +}); + +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/scripts/package-extension.js b/scripts/package-extension.js new file mode 100644 index 00000000..bdfa5c8e --- /dev/null +++ b/scripts/package-extension.js @@ -0,0 +1,89 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { TARGETS, hostTarget } = require("./prepare-resvg-runtime"); + +const root = path.resolve(__dirname, ".."); +const target = process.env.CODETOUR_TARGET || hostTarget(); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: root, + env: { ...process.env, CODETOUR_TARGET: target }, + stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", + shell: process.platform === "win32", + encoding: "utf8" + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} exited with status ${result.status}`); + } + return options.capture ? `${result.stdout || ""}${result.stderr || ""}` : ""; +} + +function main() { + if (!TARGETS[target]) { + throw new Error(`Unsupported CodeTour packaging target: ${target}`); + } + + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const vsce = path.join( + root, + "node_modules", + ".bin", + process.platform === "win32" ? "vsce.cmd" : "vsce" + ); + + run(npm, ["run", "build"]); + const listing = run(vsce, ["ls"], { capture: true }); + const packageJson = JSON.parse( + fs.readFileSync(path.join(root, "package.json"), "utf8") + ); + if (packageJson.browser) { + throw new Error("The packaged extension must not contain a browser entry point."); + } + + const manifest = JSON.parse( + fs.readFileSync(path.join(root, "dist", "resvg-runtime", "manifest.json"), "utf8") + ); + if (manifest.target !== target || manifest.binary !== TARGETS[target].binary) { + throw new Error("The staged rasterizer runtime does not match the requested VSIX target."); + } + const files = listing + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + const expectedBinary = `dist/resvg-runtime/resvg-js/${manifest.binary}`; + if (!files.some((file) => file === expectedBinary || file.endsWith(`/${expectedBinary}`))) { + throw new Error(`VSIX file list does not contain ${expectedBinary}.`); + } + if (files.some((file) => file.includes("extension-web"))) { + throw new Error("The packaged extension contains the removed Web artifact."); + } + if (files.length > 2000) { + throw new Error(`VSIX contains ${files.length} files; the packaging budget is 2000.`); + } + + run(vsce, ["package", "--target", target]); + const version = packageJson.version; + const artifact = fs + .readdirSync(root) + .filter((file) => file.endsWith(`-${target}-${version}.vsix`)) + .map((file) => path.join(root, file)) + .find((file) => fs.statSync(file).isFile()); + if (!artifact) { + throw new Error(`vsce did not create the ${version}-${target} artifact.`); + } + const size = fs.statSync(artifact).size; + if (size >= 38 * 1024 * 1024) { + throw new Error(`VSIX is ${size} bytes; it exceeds the 38 MiB packaging budget.`); + } + run(process.execPath, [path.join(root, "scripts", "verify-vsix.js"), artifact, target]); + console.log( + `Packaged ${path.basename(artifact)} for ${target}: ${size} bytes.` + ); +} + +main(); diff --git a/scripts/prepare-resvg-runtime.js b/scripts/prepare-resvg-runtime.js new file mode 100644 index 00000000..91c84c98 --- /dev/null +++ b/scripts/prepare-resvg-runtime.js @@ -0,0 +1,138 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.resolve(__dirname, ".."); +const RESVG_VERSION = "2.6.2"; + +// VS Code's standard desktop/remote Node targets. Linux artifacts are built +// against glibc; the package must be built again on a musl host if that host +// is added to the published target matrix. +const TARGETS = Object.freeze({ + "darwin-arm64": { + packageName: "@resvg/resvg-js-darwin-arm64", + binary: "resvgjs.darwin-arm64.node" + }, + "darwin-x64": { + packageName: "@resvg/resvg-js-darwin-x64", + binary: "resvgjs.darwin-x64.node" + }, + "linux-arm64": { + packageName: "@resvg/resvg-js-linux-arm64-gnu", + binary: "resvgjs.linux-arm64-gnu.node" + }, + "linux-x64": { + packageName: "@resvg/resvg-js-linux-x64-gnu", + binary: "resvgjs.linux-x64-gnu.node" + }, + "win32-arm64": { + packageName: "@resvg/resvg-js-win32-arm64-msvc", + binary: "resvgjs.win32-arm64-msvc.node" + }, + "win32-x64": { + packageName: "@resvg/resvg-js-win32-x64-msvc", + binary: "resvgjs.win32-x64-msvc.node" + } +}); + +function hostTarget() { + return `${process.platform}-${process.arch}`; +} + +function requestedTarget() { + const argumentIndex = process.argv.indexOf("--target"); + return argumentIndex >= 0 && process.argv[argumentIndex + 1] + ? process.argv[argumentIndex + 1] + : process.env.CODETOUR_TARGET || hostTarget(); +} + +function dependencyRoot() { + const candidates = [ + path.join(root, "node_modules"), + path.join(root, "packages", "description-renderer", "node_modules") + ]; + const selected = candidates.find((candidate) => + fs.existsSync(path.join(candidate, "@resvg", "resvg-js", "js-binding.js")) + ); + if (!selected) { + throw new Error( + "Unable to find @resvg/resvg-js. Install the root and description-renderer dependencies first." + ); + } + return selected; +} + +function prepareRuntime(target = requestedTarget()) { + const specification = TARGETS[target]; + if (!specification) { + throw new Error( + `Unsupported CodeTour packaging target ${target}. Supported targets: ${Object.keys(TARGETS).join(", ")}` + ); + } + if (target !== hostTarget()) { + throw new Error( + `The ${target} artifact must be built on a ${target} Node host; current host is ${hostTarget()}. ` + + "Build each VSIX on its matching CI runner so its native rasterizer is executable." + ); + } + + const modulesRoot = dependencyRoot(); + const basePackage = path.join(modulesRoot, "@resvg", "resvg-js"); + const nativePackage = path.join( + modulesRoot, + "@resvg", + specification.packageName.slice("@resvg/".length) + ); + const nativeSource = path.join(nativePackage, specification.binary); + if (!fs.existsSync(nativeSource)) { + throw new Error( + `Missing rasterizer binary ${nativeSource}. Run npm ci on a ${target} host before packaging.` + ); + } + + const baseManifest = JSON.parse( + fs.readFileSync(path.join(basePackage, "package.json"), "utf8") + ); + if (baseManifest.version !== RESVG_VERSION) { + throw new Error( + `Expected @resvg/resvg-js ${RESVG_VERSION}, found ${baseManifest.version}.` + ); + } + + const runtimeRoot = path.join(root, "dist", "resvg-runtime"); + const runtimePackage = path.join(runtimeRoot, "resvg-js"); + fs.rmSync(runtimeRoot, { recursive: true, force: true }); + fs.mkdirSync(runtimePackage, { recursive: true }); + for (const file of ["index.js", "js-binding.js"]) { + fs.copyFileSync(path.join(basePackage, file), path.join(runtimePackage, file)); + } + fs.copyFileSync(nativeSource, path.join(runtimePackage, specification.binary)); + fs.writeFileSync( + path.join(runtimePackage, "package.json"), + `${JSON.stringify({ name: "@resvg/resvg-js", version: RESVG_VERSION, main: "index.js" }, null, 2)}\n` + ); + fs.writeFileSync( + path.join(runtimeRoot, "manifest.json"), + `${JSON.stringify( + { + target, + packageName: specification.packageName, + binary: specification.binary, + version: RESVG_VERSION + }, + null, + 2 + )}\n` + ); + + return { + target, + binary: specification.binary, + runtimeRoot + }; +} + +if (require.main === module) { + console.log(JSON.stringify(prepareRuntime(), null, 2)); +} + +module.exports = { TARGETS, hostTarget, prepareRuntime }; diff --git a/scripts/publish-vsix.js b/scripts/publish-vsix.js new file mode 100644 index 00000000..0c7aaf74 --- /dev/null +++ b/scripts/publish-vsix.js @@ -0,0 +1,34 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const root = path.resolve(__dirname, ".."); +const artifacts = fs + .readdirSync(root) + .filter((file) => file.endsWith(".vsix")) + .map((file) => path.join(root, file)) + .filter((file) => fs.statSync(file).isFile()); + +if (artifacts.length !== 1) { + throw new Error(`Expected exactly one VSIX to publish, found ${artifacts.length}.`); +} + +const vsce = path.join( + root, + "node_modules", + ".bin", + process.platform === "win32" ? "vsce.cmd" : "vsce" +); +const result = spawnSync(vsce, ["publish", "--packagePath", artifacts[0]], { + cwd: root, + stdio: "inherit", + shell: process.platform === "win32" +}); +if (result.error) { + throw result.error; +} +if (result.status !== 0) { + process.exit(result.status || 1); +} + +console.log(`Published ${path.basename(artifacts[0])}.`); diff --git a/scripts/run-compiled-tests.js b/scripts/run-compiled-tests.js new file mode 100644 index 00000000..b94e38ad --- /dev/null +++ b/scripts/run-compiled-tests.js @@ -0,0 +1,28 @@ +const { spawnSync } = require("node:child_process"); +const { readdirSync } = require("node:fs"); +const { join, resolve } = require("node:path"); + +const testDirectory = resolve(process.argv[2]); +function findTestFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const entryPath = join(directory, entry.name); + return entry.isDirectory() + ? findTestFiles(entryPath) + : entry.name.endsWith(".test.js") + ? [entryPath] + : []; + }); +} + +const testFiles = findTestFiles(testDirectory).sort(); + +if (testFiles.length === 0) { + console.error(`No compiled test files found in ${testDirectory}`); + process.exit(1); +} + +const result = spawnSync(process.execPath, ["--test", ...testFiles], { + stdio: "inherit" +}); + +process.exit(result.status ?? 1); diff --git a/scripts/stage-mcp-renderer.js b/scripts/stage-mcp-renderer.js new file mode 100644 index 00000000..bea95fcd --- /dev/null +++ b/scripts/stage-mcp-renderer.js @@ -0,0 +1,39 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const packageRoot = path.resolve(__dirname, ".."); +const rendererRoot = path.join(packageRoot, "packages", "description-renderer"); +const rendererDist = path.join(rendererRoot, "dist"); +const stagedRoot = path.join( + packageRoot, + "packages", + "mcp-server", + "dist", + "node_modules", + "codetour-description-renderer" +); + +if (!fs.existsSync(rendererDist)) { + throw new Error( + "The description renderer must be built before the MCP server can be packaged." + ); +} + +fs.rmSync(stagedRoot, { recursive: true, force: true }); +fs.mkdirSync(stagedRoot, { recursive: true }); +fs.cpSync(rendererDist, path.join(stagedRoot, "dist"), { recursive: true }); + +const rendererPackage = JSON.parse( + fs.readFileSync(path.join(rendererRoot, "package.json"), "utf8") +); +fs.writeFileSync( + path.join(stagedRoot, "package.json"), + `${JSON.stringify({ + name: rendererPackage.name, + version: rendererPackage.version, + main: rendererPackage.main, + types: rendererPackage.types + }, null, 2)}\n` +); + +console.log(`Staged ${rendererPackage.name} in the MCP package.`); diff --git a/scripts/verify-resvg-runtime.js b/scripts/verify-resvg-runtime.js new file mode 100644 index 00000000..3f5284e3 --- /dev/null +++ b/scripts/verify-resvg-runtime.js @@ -0,0 +1,19 @@ +const path = require("node:path"); + +function fail(message) { + throw new Error(message); +} + +const runtime = process.argv[2]; +if (!runtime) { + fail("Usage: node scripts/verify-resvg-runtime.js "); +} + +const { Resvg } = require(path.resolve(runtime)); +const png = new Resvg( + '', + {} +).render().asPng(); +if (png.readUInt32BE(0) !== 0x89504e47) { + fail("The rasterizer did not produce a PNG."); +} diff --git a/scripts/verify-vsix.js b/scripts/verify-vsix.js new file mode 100644 index 00000000..aec8adc8 --- /dev/null +++ b/scripts/verify-vsix.js @@ -0,0 +1,119 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +function fail(message) { + throw new Error(message); +} + +function extract(artifact, destination) { + const result = process.platform === "win32" + ? spawnSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Expand-Archive -LiteralPath '${artifact.replace(/'/gu, "''")}' -DestinationPath '${destination.replace(/'/gu, "''")}' -Force` + ], + { stdio: "inherit" } + ) + : spawnSync("unzip", ["-q", "-o", artifact, "-d", destination], { + stdio: "inherit" + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + fail(`Unable to extract ${artifact}`); + } +} + +function listFiles(directory, prefix = "") { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const relative = path.join(prefix, entry.name); + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...listFiles(fullPath, relative)); + } else { + files.push(relative); + } + } + return files; +} + +function verify(artifact, target) { + const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codetour-vsix-")); + try { + extract(artifact, extractionRoot); + const extensionRoot = path.join(extractionRoot, "extension"); + const files = listFiles(extensionRoot).map((file) => file.split(path.sep).join("/")); + if (files.some((file) => file.includes("extension-web"))) { + fail("The VSIX contains the removed extension-web artifact."); + } + + const packageJson = JSON.parse( + fs.readFileSync(path.join(extensionRoot, "package.json"), "utf8") + ); + if (packageJson.browser || packageJson.main !== "./dist/extension-node.js") { + fail("The VSIX package manifest is not Node-extension-only."); + } + + const manifest = JSON.parse( + fs.readFileSync(path.join(extensionRoot, "dist", "resvg-runtime", "manifest.json"), "utf8") + ); + if (manifest.target !== target) { + fail(`The VSIX runtime target is ${manifest.target}, expected ${target}.`); + } + const binary = path.join( + extensionRoot, + "dist", + "resvg-runtime", + "resvg-js", + manifest.binary + ); + if (!fs.existsSync(binary)) { + fail(`The VSIX is missing its native rasterizer: ${manifest.binary}.`); + } + + const resvg = spawnSync( + process.execPath, + [ + path.join(__dirname, "verify-resvg-runtime.js"), + path.join(extensionRoot, "dist", "resvg-runtime", "resvg-js") + ], + { encoding: "utf8" } + ); + if (resvg.error) { + throw resvg.error; + } + if (resvg.status !== 0) { + fail( + `The unpacked VSIX rasterizer did not produce a PNG: ${resvg.stderr || resvg.stdout}` + ); + } + + const mcp = spawnSync( + process.execPath, + [path.join(extensionRoot, "dist", "mcp-server.js"), "--version"], + { cwd: extensionRoot, encoding: "utf8" } + ); + if (mcp.status !== 0 || !/^\d+\.\d+\.\d+/u.test((mcp.stdout || "").trim())) { + fail(`The unpacked MCP server did not start: ${mcp.stderr || mcp.stdout}`); + } + + const size = fs.statSync(artifact).size; + console.log(`Verified unpacked ${path.basename(artifact)}: ${files.length} files, ${size} bytes, PNG OK.`); + } finally { + fs.rmSync(extractionRoot, { recursive: true, force: true }); + } +} + +const artifact = process.argv[2]; +const target = process.argv[3]; +if (!artifact || !target) { + fail("Usage: node scripts/verify-vsix.js "); +} +verify(path.resolve(artifact), target); 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" + } + } +} diff --git a/src/codex.ts b/src/codex.ts new file mode 100644 index 00000000..398e4743 --- /dev/null +++ b/src/codex.ts @@ -0,0 +1,108 @@ +import { execFile } from "child_process"; +import { promisify } from "util"; +import * as vscode from "vscode"; +import { bundledMcpServerPath } from "./mcp"; + +const execFileAsync = promisify(execFile); +const CODEX_EXECUTABLE = "codex"; +const SERVER_NAME = "codetour"; + +interface CodexServerConfiguration { + transport?: { + type?: string; + command?: string; + args?: string[]; + }; +} + +async function runCodex(args: string[]): Promise { + const result = await execFileAsync(CODEX_EXECUTABLE, args, { + encoding: "utf8", + windowsHide: true + }); + return result.stdout; +} + +async function currentConfiguration(): Promise { + try { + return JSON.parse( + await runCodex(["mcp", "get", SERVER_NAME, "--json"]) + ) as CodexServerConfiguration; + } catch (error) { + const exitCode = (error as { code?: unknown }).code; + if (typeof exitCode === "number") { + return undefined; + } + throw error; + } +} + +function isExpectedConfiguration( + configuration: CodexServerConfiguration | undefined, + serverPath: string +): boolean { + return ( + configuration?.transport?.type === "stdio" && + configuration.transport.command === "node" && + configuration.transport.args?.length === 1 && + configuration.transport.args[0] === serverPath + ); +} + +async function addConfiguration(serverPath: string): Promise { + await runCodex(["mcp", "add", SERVER_NAME, "--", "node", serverPath]); +} + +async function configureCodex(context: vscode.ExtensionContext): Promise { + const serverPath = bundledMcpServerPath(context); + const current = await currentConfiguration(); + if (isExpectedConfiguration(current, serverPath)) { + void vscode.window.showInformationMessage("CodeTour is already configured for Codex."); + return; + } + if (current) { + const repair = "Repair configuration"; + const selected = await vscode.window.showWarningMessage( + "Codex already has a different CodeTour MCP configuration.", + repair + ); + if (selected === repair) { + await repairCodex(context); + } + return; + } + await addConfiguration(serverPath); + void vscode.window.showInformationMessage("CodeTour MCP was configured for Codex."); +} + +async function repairCodex(context: vscode.ExtensionContext): Promise { + const serverPath = bundledMcpServerPath(context); + const current = await currentConfiguration(); + if (current) { + await runCodex(["mcp", "remove", SERVER_NAME]); + } + await addConfiguration(serverPath); + void vscode.window.showInformationMessage("CodeTour MCP configuration for Codex was repaired."); +} + +async function reportFailure(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + void vscode.window.showErrorMessage( + `Unable to configure CodeTour for Codex. Make sure the Codex CLI and Node.js 18 or newer are available: ${message}` + ); + } +} + +export function registerCodexCommands(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand("codetour.configureCodexMcp", () => + reportFailure(() => configureCodex(context)) + ), + vscode.commands.registerCommand("codetour.repairCodexMcp", () => + reportFailure(() => repairCodex(context)) + ) + ); +} diff --git a/src/desktopIntegration.ts b/src/desktopIntegration.ts new file mode 100644 index 00000000..eef7b8aa --- /dev/null +++ b/src/desktopIntegration.ts @@ -0,0 +1,12 @@ +import * as vscode from "vscode"; +import { registerCodexCommands } from "./codex"; +import { registerMcpProvider } from "./mcp"; + +export function registerDesktopIntegrations(context: vscode.ExtensionContext): void { + if (vscode.env.uiKind !== vscode.UIKind.Desktop) { + return; + } + + registerMcpProvider(context); + registerCodexCommands(context); +} diff --git a/src/extension.ts b/src/extension.ts index b72a4b8c..c5d40060 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 { registerNotebookProvider } from "./notebook"; import { registerPlayerModule } from "./player"; import { registerRecorderModule } from "./recorder"; import { store } from "./store"; @@ -14,11 +15,12 @@ import { startDefaultTour } from "./store/actions"; import { discoverTours as _discoverTours } from "./store/provider"; +import { registerDesktopIntegrations } from "./desktopIntegration"; /** - * In order to check whether the URI handler was called on activation, - * we must do this dance around `discoverTours`. The same call to - * `discoverTours` is shared between `activate` and the URI handler. + * Partage la découverte des visites entre l'ouverture normale du projet et + * l'ouverture via un lien CodeTour. Une seule lecture des fichiers est ainsi + * nécessaire avant d'afficher la visite demandée à l'utilisateur. */ let cachedDiscoverTours: Promise | undefined; function discoverTours(): Promise { @@ -31,8 +33,8 @@ function startTour(params: URLSearchParams) { let stepNumber; if (step) { - // Allow the step number to be - // provided as 1-based vs. 0-based + // Dans les liens publics, la première étape porte le numéro 1 ; le lecteur + // interne utilise un index qui commence à 0. stepNumber = Number(step) - 1; } @@ -75,6 +77,11 @@ class URIHandler implements vscode.UriHandler { } export async function activate(context: vscode.ExtensionContext) { + registerDesktopIntegrations(context); + const notebookProvider = registerNotebookProvider(); + if (notebookProvider) { + context.subscriptions.push(notebookProvider); + } registerPlayerModule(context); registerRecorderModule(); registerLiveShareModule(); diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 00000000..25164a80 --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,29 @@ +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 => { + const definition = new vscode.McpStdioServerDefinition( + `CodeTour (${folder.name})`, + process.execPath, + [serverPath], + { ELECTRON_RUN_AS_NODE: "1" }, + extensionVersion + ); + definition.cwd = folder.uri; + return definition; + }) + }) + ); +} 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..fcb2d1ba 100644 --- a/src/player/commands.ts +++ b/src/player/commands.ts @@ -21,8 +21,24 @@ 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. + // Internal inspection seam used by the development-host smoke test. It + // reads the comment owned by the active player, not a renderer-side value. + 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; + } + ); + + // Permet à l'aperçu d'un marqueur d'ouvrir directement la visite et l'étape + // correspondantes ; cette commande n'est pas destinée à la palette publique. vscode.commands.registerCommand( `${EXTENSION_NAME}._startTourById`, async (id: string, stepNumber: number) => { @@ -33,7 +49,7 @@ export function registerPlayerCommands() { } ); - // Purpose: Command link + // Ouvre une visite liée depuis le contenu Markdown d'une autre visite. vscode.commands.registerCommand( `${EXTENSION_NAME}.startTourByTitle`, async (title: string, stepNumber?: number) => { @@ -68,7 +84,7 @@ export function registerPlayerCommands() { } ); - // Purpose: Command link + // Amène le lecteur à l'étape ciblée par un lien Markdown. vscode.commands.registerCommand( `${EXTENSION_NAME}.navigateToStep`, async (stepNumber: number) => { @@ -83,7 +99,7 @@ export function registerPlayerCommands() { } ); - // Purpose: Command link and the ">>" syntax + // Exécute dans le terminal CodeTour le texte proposé par une étape. 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..b0028663 --- /dev/null +++ b/src/player/description.ts @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ColorThemeKind, Uri, window, workspace } from "vscode"; +import { + DescriptionTheme, + renderDescription +} from "codetour-description-renderer"; +import { CodeTour, store } from "../store"; +import { getTourTitle } from "../utils"; +import { appendInsertCodeLinks } from "./insertCode"; + +const SHELL_SCRIPT_PATTERN = /^>>\s+(?