From fc0b75a8d0f9373cdd1657f194e9b56458b8501a Mon Sep 17 00:00:00 2001 From: yannbf Date: Tue, 2 Jun 2026 08:44:37 +0200 Subject: [PATCH 1/7] feat(addon-mcp): add get-stories-by-component tool and harden change detection Adds the get-stories-by-component tool backed by Storybook's reverse dependency graph, mapping component source files to the stories that render them so agents can hand real story IDs to preview-stories / display-review. Includes the change-detection audit fixes: - dedupe component paths and canonicalise via fs.realpathSync.native - distinguish "path not found" from "no stories yet" - definitive graph-not-built messaging (no misleading "retry shortly") - maxDistance schema validation + internal default, telemetry on resolved value - unreachable-working-tree-change hints for get-changed-stories - resolve-component-stories extraction + dev-instructions workflow guidance Co-Authored-By: Claude Opus 4.8 --- .../tests/mcp-endpoint.e2e.test.ts | 118 ++++++- .../build-server-instructions.test.ts | 70 +++- .../instructions/build-server-instructions.ts | 22 +- .../src/instructions/dev-instructions.md | 11 + packages/addon-mcp/src/mcp-handler.ts | 15 +- .../src/tools/display-review.test.ts | 103 +++++- .../addon-mcp/src/tools/display-review.ts | 51 ++- .../src/tools/get-changed-stories.test.ts | 113 +++++- .../src/tools/get-changed-stories.ts | 32 +- .../tools/get-stories-by-component.test.ts | 287 +++++++++++++++ .../src/tools/get-stories-by-component.ts | 326 ++++++++++++++++++ packages/addon-mcp/src/tools/tool-names.ts | 1 + .../addon-mcp/src/utils/change-detection.ts | 41 +++ .../utils/detect-unreachable-changes.test.ts | 194 +++++++++++ .../src/utils/detect-unreachable-changes.ts | 116 +++++++ .../utils/format-validation-issues.test.ts | 111 ++++++ .../src/utils/format-validation-issues.ts | 112 ++++++ .../utils/resolve-component-stories.test.ts | 204 +++++++++++ .../src/utils/resolve-component-stories.ts | 181 ++++++++++ 19 files changed, 2088 insertions(+), 20 deletions(-) create mode 100644 packages/addon-mcp/src/tools/get-stories-by-component.test.ts create mode 100644 packages/addon-mcp/src/tools/get-stories-by-component.ts create mode 100644 packages/addon-mcp/src/utils/change-detection.ts create mode 100644 packages/addon-mcp/src/utils/detect-unreachable-changes.test.ts create mode 100644 packages/addon-mcp/src/utils/detect-unreachable-changes.ts create mode 100644 packages/addon-mcp/src/utils/format-validation-issues.test.ts create mode 100644 packages/addon-mcp/src/utils/format-validation-issues.ts create mode 100644 packages/addon-mcp/src/utils/resolve-component-stories.test.ts create mode 100644 packages/addon-mcp/src/utils/resolve-component-stories.ts diff --git a/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts b/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts index f4a4b09a..d3cf3dbb 100644 --- a/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts +++ b/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts @@ -100,7 +100,7 @@ describe('MCP Endpoint E2E Tests', () => { expect(response.result).toHaveProperty('tools'); // Dev, docs, and test tools should be present - expect(response.result.tools).toHaveLength(7); + expect(response.result.tools).toHaveLength(8); expect(response.result.tools).toMatchInlineSnapshot(` [ @@ -378,6 +378,121 @@ describe('MCP Endpoint E2E Tests', () => { "name": "get-changed-stories", "title": "Get changed stories metadata", }, + { + "description": "Map component source files to the stories that render them, so you can hand real story IDs to preview-stories instead of guessing. + + **When to use this vs \`get-changed-stories\`:** if the user just edited code, call \`get-changed-stories\` first — it reads Storybook's live git-diff signal for free. Only call this tool when you need to map specific file paths to stories: the user described a feature/area by name, \`get-changed-stories\` returned nothing (the change is outside the story graph and you need to find runtime consumers yourself), or it returned too much and you need to narrow. + + **Use this whenever the user describes a part of the UI by feature, area, or topic** ("review the credit-card components", "preview every checkout story", "show me what cart looks like", "stories related to authentication") — first locate the relevant component files in the repo (grep/Glob), then pass their absolute paths here. The tool returns grounded \`storyId\` values from the live Storybook index; never invent IDs from file names, feature names, or memory. + + Returns sorted results from the Storybook index — if a component has no matches here, it likely has no stories yet (say so, don't fabricate). + + Backed by Storybook's live change-detection reverse dependency graph: distance is the import-graph hop count from the story file to the component (1 = directly imported, 2+ = transitively). Requires \`features.changeDetection\` to be enabled in the Storybook config; if not, the tool returns a typed error. + + Results are sorted by \`distance\` (lower = stronger signal). Prefer the lowest-distance results first; widen only when needed. For shared components like Button or Icon, expect many indirect (\`distance\` ≥ 2) matches — pass \`maxDistance\` to cap noise.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "componentPaths": { + "description": "Absolute paths to component source files (e.g. "/repo/src/Button.tsx"). + Pass the components you actually want stories for — typically files you just read, edited, or that the user mentioned. + Do not pass story files (\`*.stories.*\`); pass the component the story renders.", + "items": { + "type": "string", + }, + "minItems": 1, + "type": "array", + }, + "maxDistance": { + "description": "Optional ceiling on the import depth to include in results. + - 1: only stories that directly import the component. + - 2+: also include stories that reach the component through N hops. + Omit to include everything. Lower values trade recall for precision; useful when one shared component (Button, Icon, …) would otherwise sweep in dozens of consumer stories.", + "type": "number", + }, + }, + "required": [ + "componentPaths", + ], + "type": "object", + }, + "name": "get-stories-by-component", + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "results": { + "items": { + "properties": { + "clipped": { + "description": "Present only when \`maxDistance\` filtered out one or more matches. \`count\` is how many were dropped; \`distances\` lists the (sorted, distinct) distances those dropped matches sat at — widen \`maxDistance\` to include them.", + "properties": { + "count": { + "type": "number", + }, + "distances": { + "items": { + "type": "number", + }, + "type": "array", + }, + }, + "required": [ + "count", + "distances", + ], + "type": "object", + }, + "componentPath": { + "type": "string", + }, + "matches": { + "items": { + "properties": { + "distance": { + "description": "Import-graph depth from the story file to the component (lower = stronger). 1: story file directly imports the component. 2+: reached through N hops.", + "type": "number", + }, + "importPath": { + "type": "string", + }, + "name": { + "type": "string", + }, + "storyId": { + "type": "string", + }, + "title": { + "type": "string", + }, + }, + "required": [ + "storyId", + "title", + "name", + "importPath", + "distance", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "componentPath", + "matches", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "results", + ], + "type": "object", + }, + "title": "Get stories for component files", + }, { "description": "Run story tests. Provide stories for focused runs (faster while iterating), @@ -901,6 +1016,7 @@ describe('MCP Endpoint E2E Tests', () => { "preview-stories", "get-storybook-story-instructions", "get-changed-stories", + "get-stories-by-component", ] `); }); diff --git a/packages/addon-mcp/src/instructions/build-server-instructions.test.ts b/packages/addon-mcp/src/instructions/build-server-instructions.test.ts index be3800cc..e9e2a373 100644 --- a/packages/addon-mcp/src/instructions/build-server-instructions.test.ts +++ b/packages/addon-mcp/src/instructions/build-server-instructions.test.ts @@ -21,7 +21,18 @@ describe('buildServerInstructions', () => { - After changing any component or story, call **get-changed-stories** to discover new/modified/related stories, then call **preview-stories** to retrieve preview URLs. - In final user-facing responses, order links consistently: review page first (if available), changed-stories fallback next (if relevant), then specific preview URLs. - Always include every returned preview URL in your user-facing response so the user can verify the visual result. - - After completing the change, call **display-review** to publish a curated review to Storybook's review page. If the session has a browser-preview tool, navigate it to the returned \`reviewUrl\` so the user sees the review without leaving the chat. Always include the \`reviewUrl\` in your final response as a fallback. Call this tool again whenever the user iterates on the changes, so you keep the review up to date. + - After a UI change, call **display-review** to publish a curated review — but only when the change is expected to be visually observable. Pure refactors with no rendering impact (type-only edits, internal renames, dead-code removal, comment/import reorg) don't need a review; skip the call, or publish a single small collection and note in the description that no visible change is expected. When you're unsure whether a refactor has visual side-effects, publish the review and say so. If the session has a browser-preview tool, navigate it to the returned \`reviewUrl\` so the user sees the review without leaving the chat. Always include the \`reviewUrl\` in your final response as a fallback. Call this tool again whenever the user iterates on the changes. + + ## Mapping any input to story IDs + + Whenever you need story IDs — to preview them, to feed \`display-review\`, to answer the user, for any reason at all — your job is the same regardless of how the request reached you. The input can take any shape: a feature/domain/topic the user named, a file the user mentioned, a file you just edited, a query like "all consumers of X", an autonomous review after a UI change, or anything else. The chain doesn't change with the prompt shape: + + 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is *shared* infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass *their* paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made *multiple* edits in the same session, \`get-changed-stories\` returns the *cumulative* diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call \`get-stories-by-component\` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. + 2. **Call \`get-stories-by-component\`** with those absolute paths. It returns grounded \`storyId\` values from the live Storybook index, ranked by \`distance\` (0 = colocated, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like \`list-all-documentation\`) are safe to use. + 3. **Bucket by \`distance\`.** Default to \`maxDistance: 2\` and tighten only when you have a reason to. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the \`distance 1\` bucket is often empty — capping at \`1\` would hide the entire cascade. Page-level components can also see surprising \`distance 3+\` matches when Storybook decorators pull in wide swaths of the app; \`maxDistance: 2\` defuses that without losing real consumers. For \`display-review\`, the buckets map directly onto the visual cascade: \`0\` = the component itself, \`1\` = direct importers, \`2+\` (capped via \`maxDistance\`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. + 4. **Pass the resulting \`storyId\`s into \`preview-stories\`** for preview URLs, or into \`display-review\` for a curated review page (per the visibility guidance above — skip review for changes with no expected visual impact). + + Never invent story IDs from file names, feature names, or memory — Storybook IDs come from the live index and are the only ones that resolve. If \`get-stories-by-component\` returns no matches for a component, that component genuinely has no stories yet; tell the user rather than fabricating IDs. \`display-review\` validates every ID against the live index server-side and will hard-fail the whole review if any are unknown — there is no "soft" guess that slips through. ## Validation Workflow @@ -69,7 +80,18 @@ describe('buildServerInstructions', () => { - After changing any component or story, call **get-changed-stories** to discover new/modified/related stories, then call **preview-stories** to retrieve preview URLs. - In final user-facing responses, order links consistently: review page first (if available), changed-stories fallback next (if relevant), then specific preview URLs. - Always include every returned preview URL in your user-facing response so the user can verify the visual result. - - After completing the change, call **display-review** to publish a curated review to Storybook's review page. If the session has a browser-preview tool, navigate it to the returned \`reviewUrl\` so the user sees the review without leaving the chat. Always include the \`reviewUrl\` in your final response as a fallback. Call this tool again whenever the user iterates on the changes, so you keep the review up to date." + - After a UI change, call **display-review** to publish a curated review — but only when the change is expected to be visually observable. Pure refactors with no rendering impact (type-only edits, internal renames, dead-code removal, comment/import reorg) don't need a review; skip the call, or publish a single small collection and note in the description that no visible change is expected. When you're unsure whether a refactor has visual side-effects, publish the review and say so. If the session has a browser-preview tool, navigate it to the returned \`reviewUrl\` so the user sees the review without leaving the chat. Always include the \`reviewUrl\` in your final response as a fallback. Call this tool again whenever the user iterates on the changes. + + ## Mapping any input to story IDs + + Whenever you need story IDs — to preview them, to feed \`display-review\`, to answer the user, for any reason at all — your job is the same regardless of how the request reached you. The input can take any shape: a feature/domain/topic the user named, a file the user mentioned, a file you just edited, a query like "all consumers of X", an autonomous review after a UI change, or anything else. The chain doesn't change with the prompt shape: + + 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is *shared* infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass *their* paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made *multiple* edits in the same session, \`get-changed-stories\` returns the *cumulative* diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call \`get-stories-by-component\` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. + 2. **Call \`get-stories-by-component\`** with those absolute paths. It returns grounded \`storyId\` values from the live Storybook index, ranked by \`distance\` (0 = colocated, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like \`list-all-documentation\`) are safe to use. + 3. **Bucket by \`distance\`.** Default to \`maxDistance: 2\` and tighten only when you have a reason to. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the \`distance 1\` bucket is often empty — capping at \`1\` would hide the entire cascade. Page-level components can also see surprising \`distance 3+\` matches when Storybook decorators pull in wide swaths of the app; \`maxDistance: 2\` defuses that without losing real consumers. For \`display-review\`, the buckets map directly onto the visual cascade: \`0\` = the component itself, \`1\` = direct importers, \`2+\` (capped via \`maxDistance\`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. + 4. **Pass the resulting \`storyId\`s into \`preview-stories\`** for preview URLs, or into \`display-review\` for a curated review page (per the visibility guidance above — skip review for changes with no expected visual impact). + + Never invent story IDs from file names, feature names, or memory — Storybook IDs come from the live index and are the only ones that resolve. If \`get-stories-by-component\` returns no matches for a component, that component genuinely has no stories yet; tell the user rather than fabricating IDs. \`display-review\` validates every ID against the live index server-side and will hard-fail the whole review if any are unknown — there is no "soft" guess that slips through." `); }); @@ -90,10 +112,39 @@ describe('buildServerInstructions', () => { - Treat that tool's output as the source of truth for framework-specific imports, story patterns, and testing conventions. - After changing any component or story, call **preview-stories** to retrieve preview URLs. - In final user-facing responses, order links consistently: review page first (if available), changed-stories fallback next (if relevant), then specific preview URLs. - - Always include every returned preview URL in your user-facing response so the user can verify the visual result." + - Always include every returned preview URL in your user-facing response so the user can verify the visual result. + + ## Mapping any input to story IDs + + Whenever you need story IDs — to preview them, to feed \`display-review\`, to answer the user, for any reason at all — your job is the same regardless of how the request reached you. The input can take any shape: a feature/domain/topic the user named, a file the user mentioned, a file you just edited, a query like "all consumers of X", an autonomous review after a UI change, or anything else. The chain doesn't change with the prompt shape: + + 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is *shared* infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass *their* paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made *multiple* edits in the same session, \`get-changed-stories\` returns the *cumulative* diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call \`get-stories-by-component\` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. + 2. **Call \`get-stories-by-component\`** with those absolute paths. It returns grounded \`storyId\` values from the live Storybook index, ranked by \`distance\` (0 = colocated, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like \`list-all-documentation\`) are safe to use. + 3. **Bucket by \`distance\`.** Default to \`maxDistance: 2\` and tighten only when you have a reason to. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the \`distance 1\` bucket is often empty — capping at \`1\` would hide the entire cascade. Page-level components can also see surprising \`distance 3+\` matches when Storybook decorators pull in wide swaths of the app; \`maxDistance: 2\` defuses that without losing real consumers. For \`display-review\`, the buckets map directly onto the visual cascade: \`0\` = the component itself, \`1\` = direct importers, \`2+\` (capped via \`maxDistance\`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. + 4. **Pass the resulting \`storyId\`s into \`preview-stories\`** for preview URLs, or into \`display-review\` for a curated review page (per the visibility guidance above — skip review for changes with no expected visual impact). + + Never invent story IDs from file names, feature names, or memory — Storybook IDs come from the live index and are the only ones that resolve. If \`get-stories-by-component\` returns no matches for a component, that component genuinely has no stories yet; tell the user rather than fabricating IDs. \`display-review\` validates every ID against the live index server-side and will hard-fail the whole review if any are unknown — there is no "soft" guess that slips through." `); }); + it('falls back to get-stories-by-component when change detection is off but the dependency graph is available', () => { + const instructions = buildServerInstructions({ + devEnabled: true, + testEnabled: false, + docsEnabled: false, + changeDetectionEnabled: false, + dependencyGraphAvailable: true, + }); + + // The status-store-driven get-changed-stories isn't registered, but the reverse + // dependency graph is — so the workflow should route the agent through + // get-stories-by-component rather than the bare preview-stories line. + expect(instructions).toContain( + '- After changing any component or story, call **get-stories-by-component** with the absolute paths of the files you touched to find the stories that render them, then call **preview-stories** to retrieve preview URLs.', + ); + expect(instructions).not.toContain('call **get-changed-stories**'); + }); + it('omits display-review step when reviewEnabled is false even with change detection on', () => { const instructions = buildServerInstructions({ devEnabled: true, @@ -112,7 +163,18 @@ describe('buildServerInstructions', () => { - Treat that tool's output as the source of truth for framework-specific imports, story patterns, and testing conventions. - After changing any component or story, call **get-changed-stories** to discover new/modified/related stories, then call **preview-stories** to retrieve preview URLs. - In final user-facing responses, order links consistently: review page first (if available), changed-stories fallback next (if relevant), then specific preview URLs. - - Always include every returned preview URL in your user-facing response so the user can verify the visual result." + - Always include every returned preview URL in your user-facing response so the user can verify the visual result. + + ## Mapping any input to story IDs + + Whenever you need story IDs — to preview them, to feed \`display-review\`, to answer the user, for any reason at all — your job is the same regardless of how the request reached you. The input can take any shape: a feature/domain/topic the user named, a file the user mentioned, a file you just edited, a query like "all consumers of X", an autonomous review after a UI change, or anything else. The chain doesn't change with the prompt shape: + + 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is *shared* infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass *their* paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made *multiple* edits in the same session, \`get-changed-stories\` returns the *cumulative* diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call \`get-stories-by-component\` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. + 2. **Call \`get-stories-by-component\`** with those absolute paths. It returns grounded \`storyId\` values from the live Storybook index, ranked by \`distance\` (0 = colocated, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like \`list-all-documentation\`) are safe to use. + 3. **Bucket by \`distance\`.** Default to \`maxDistance: 2\` and tighten only when you have a reason to. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the \`distance 1\` bucket is often empty — capping at \`1\` would hide the entire cascade. Page-level components can also see surprising \`distance 3+\` matches when Storybook decorators pull in wide swaths of the app; \`maxDistance: 2\` defuses that without losing real consumers. For \`display-review\`, the buckets map directly onto the visual cascade: \`0\` = the component itself, \`1\` = direct importers, \`2+\` (capped via \`maxDistance\`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. + 4. **Pass the resulting \`storyId\`s into \`preview-stories\`** for preview URLs, or into \`display-review\` for a curated review page (per the visibility guidance above — skip review for changes with no expected visual impact). + + Never invent story IDs from file names, feature names, or memory — Storybook IDs come from the live index and are the only ones that resolve. If \`get-stories-by-component\` returns no matches for a component, that component genuinely has no stories yet; tell the user rather than fabricating IDs. \`display-review\` validates every ID against the live index server-side and will hard-fail the whole review if any are unknown — there is no "soft" guess that slips through." `); }); diff --git a/packages/addon-mcp/src/instructions/build-server-instructions.ts b/packages/addon-mcp/src/instructions/build-server-instructions.ts index 18930b7b..b87cbe7b 100644 --- a/packages/addon-mcp/src/instructions/build-server-instructions.ts +++ b/packages/addon-mcp/src/instructions/build-server-instructions.ts @@ -7,6 +7,13 @@ export type BuildServerInstructionsOptions = { testEnabled: boolean; docsEnabled: boolean; changeDetectionEnabled?: boolean; + /** + * `get-stories-by-component` is registered whenever the dev-server exposes the dependency + * graph — even if `features.changeDetection` is off and `get-changed-stories` is unavailable. + * When true and `changeDetectionEnabled` is false, the workflow falls back to manual lookup + * via `get-stories-by-component` instead of the status-store-driven `get-changed-stories`. + */ + dependencyGraphAvailable?: boolean; reviewEnabled?: boolean; }; @@ -15,19 +22,20 @@ export function buildServerInstructions(options: BuildServerInstructionsOptions) if (options.devEnabled) { const changeDetection = options.changeDetectionEnabled ?? false; + const graphAvailable = options.dependencyGraphAvailable ?? false; const reviewEnabled = options.reviewEnabled ?? false; + const previewStoriesStep = changeDetection + ? 'After changing any component or story, call **get-changed-stories** to discover new/modified/related stories, then call **preview-stories** to retrieve preview URLs.' + : graphAvailable + ? 'After changing any component or story, call **get-stories-by-component** with the absolute paths of the files you touched to find the stories that render them, then call **preview-stories** to retrieve preview URLs.' + : 'After changing any component or story, call **preview-stories** to retrieve preview URLs.'; sections.push( devInstructions - .replace( - '{{PREVIEW_STORIES_STEP}}', - changeDetection - ? 'After changing any component or story, call **get-changed-stories** to discover new/modified/related stories, then call **preview-stories** to retrieve preview URLs.' - : 'After changing any component or story, call **preview-stories** to retrieve preview URLs.', - ) + .replace('{{PREVIEW_STORIES_STEP}}', previewStoriesStep) .replace( '{{DISPLAY_REVIEW_STEP}}', reviewEnabled - ? "\n- After completing the change, call **display-review** to publish a curated review to Storybook's review page. If the session has a browser-preview tool, navigate it to the returned `reviewUrl` so the user sees the review without leaving the chat. Always include the `reviewUrl` in your final response as a fallback. Call this tool again whenever the user iterates on the changes, so you keep the review up to date." + ? "\n- After a UI change, call **display-review** to publish a curated review — but only when the change is expected to be visually observable. Pure refactors with no rendering impact (type-only edits, internal renames, dead-code removal, comment/import reorg) don't need a review; skip the call, or publish a single small collection and note in the description that no visible change is expected. When you're unsure whether a refactor has visual side-effects, publish the review and say so. If the session has a browser-preview tool, navigate it to the returned `reviewUrl` so the user sees the review without leaving the chat. Always include the `reviewUrl` in your final response as a fallback. Call this tool again whenever the user iterates on the changes." : '', ) .trim(), diff --git a/packages/addon-mcp/src/instructions/dev-instructions.md b/packages/addon-mcp/src/instructions/dev-instructions.md index 8072e145..6d516f23 100644 --- a/packages/addon-mcp/src/instructions/dev-instructions.md +++ b/packages/addon-mcp/src/instructions/dev-instructions.md @@ -5,3 +5,14 @@ - {{PREVIEW_STORIES_STEP}} - In final user-facing responses, order links consistently: review page first (if available), changed-stories fallback next (if relevant), then specific preview URLs. - Always include every returned preview URL in your user-facing response so the user can verify the visual result.{{DISPLAY_REVIEW_STEP}} + +## Mapping any input to story IDs + +Whenever you need story IDs — to preview them, to feed `display-review`, to answer the user, for any reason at all — your job is the same regardless of how the request reached you. The input can take any shape: a feature/domain/topic the user named, a file the user mentioned, a file you just edited, a query like "all consumers of X", an autonomous review after a UI change, or anything else. The chain doesn't change with the prompt shape: + +1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is *shared* infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass *their* paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made *multiple* edits in the same session, `get-changed-stories` returns the *cumulative* diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call `get-stories-by-component` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. +2. **Call `get-stories-by-component`** with those absolute paths. It returns grounded `storyId` values from the live Storybook index, ranked by `distance` (0 = colocated, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like `list-all-documentation`) are safe to use. +3. **Bucket by `distance`.** Default to `maxDistance: 2` and tighten only when you have a reason to. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the `distance 1` bucket is often empty — capping at `1` would hide the entire cascade. Page-level components can also see surprising `distance 3+` matches when Storybook decorators pull in wide swaths of the app; `maxDistance: 2` defuses that without losing real consumers. For `display-review`, the buckets map directly onto the visual cascade: `0` = the component itself, `1` = direct importers, `2+` (capped via `maxDistance`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. +4. **Pass the resulting `storyId`s into `preview-stories`** for preview URLs, or into `display-review` for a curated review page (per the visibility guidance above — skip review for changes with no expected visual impact). + +Never invent story IDs from file names, feature names, or memory — Storybook IDs come from the live index and are the only ones that resolve. If `get-stories-by-component` returns no matches for a component, that component genuinely has no stories yet; tell the user rather than fabricating IDs. `display-review` validates every ID against the live index server-side and will hard-fail the whole review if any are unknown — there is no "soft" guess that slips through. diff --git a/packages/addon-mcp/src/mcp-handler.ts b/packages/addon-mcp/src/mcp-handler.ts index 9928a6f2..dab22f06 100644 --- a/packages/addon-mcp/src/mcp-handler.ts +++ b/packages/addon-mcp/src/mcp-handler.ts @@ -4,6 +4,7 @@ import { HttpTransport } from '@tmcp/transport-http'; import pkgJson from '../package.json' with { type: 'json' }; import { addPreviewStoriesTool } from './tools/preview-stories.ts'; import { addGetChangedStoriesTool } from './tools/get-changed-stories.ts'; +import { addGetStoriesByComponentTool } from './tools/get-stories-by-component.ts'; import { addDisplayReviewTool } from './tools/display-review.ts'; import { addGetUIBuildingInstructionsTool } from './tools/get-storybook-story-instructions.ts'; import { @@ -26,6 +27,7 @@ import { isAddonA11yEnabled } from './utils/is-addon-a11y-enabled.ts'; import type { CompositionAuth } from './auth/index.ts'; import { buildServerInstructions } from './instructions/build-server-instructions.ts'; import { DEFAULT_MCP_ENDPOINT } from './constants.ts'; +import { isDependencyGraphSupported } from './utils/change-detection.ts'; let transport: HttpTransport | undefined; let origin: string | undefined; @@ -37,7 +39,12 @@ let a11yEnabled: boolean | undefined; const initializeMCPServer = async (options: Options, multiSource?: boolean) => { const core = await options.presets.apply('core', {}); const features = await options.presets.apply('features', {}); - const changeDetectionEnabled = features?.changeDetection ?? false; + // The dependency graph and the change-detection status pipeline are independent in Storybook: + // the graph runs whenever the dev-server has a supporting builder; `features.changeDetection` + // only gates the status pipeline that powers `get-changed-stories`. + const dependencyGraphSupported = await isDependencyGraphSupported(); + const changeDetectionEnabled = + (features?.changeDetection ?? false) && dependencyGraphSupported; disableTelemetry = core?.disableTelemetry ?? false; // Determine tool availability before creating server so instructions can be tailored. @@ -58,6 +65,7 @@ const initializeMCPServer = async (options: Options, multiSource?: boolean) => { testEnabled: (server?.ctx.custom?.toolsets?.test ?? true) && !!addonVitestConstants, docsEnabled: (server?.ctx.custom?.toolsets?.docs ?? true) && manifestStatus.available, changeDetectionEnabled, + dependencyGraphAvailable: dependencyGraphSupported, reviewEnabled: reviewStatus.available, }); }, @@ -90,6 +98,11 @@ const initializeMCPServer = async (options: Options, multiSource?: boolean) => { await addGetChangedStoriesTool(server); } + // get-stories-by-component only needs the dependency graph, not the status pipeline. + if (dependencyGraphSupported) { + await addGetStoriesByComponentTool(server); + } + if (reviewStatus.available) { await addDisplayReviewTool(server); } diff --git a/packages/addon-mcp/src/tools/display-review.test.ts b/packages/addon-mcp/src/tools/display-review.test.ts index 9d1efa55..df2faedc 100644 --- a/packages/addon-mcp/src/tools/display-review.test.ts +++ b/packages/addon-mcp/src/tools/display-review.test.ts @@ -1,10 +1,27 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { McpServer } from 'tmcp'; import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot'; import { addDisplayReviewTool, buildReviewUrl, type ReviewState } from './display-review.ts'; import { DISPLAY_REVIEW_TOOL_NAME } from './tool-names.ts'; import { PUSH_REVIEW_EVENT } from '../constants.ts'; import type { AddonContext } from '../types.ts'; +import * as fetchStoryIndex from '../utils/fetch-story-index.ts'; +import type { StoryIndex } from 'storybook/internal/types'; + +function makeIndex(ids: string[]): StoryIndex { + const entries: StoryIndex['entries'] = {}; + for (const id of ids) { + entries[id] = { + id, + type: 'story', + title: 'X', + name: id, + importPath: './x.stories.ts', + tags: [], + } as unknown as StoryIndex['entries'][string]; + } + return { v: 5, entries } as unknown as StoryIndex; +} const sampleReview: ReviewState = { title: 'Recolour the primary button', @@ -100,6 +117,11 @@ describe('displayReviewTool', () => { beforeEach(async () => { emitted = []; + // Default: every story ID used in the sampleReview resolves. Individual + // tests override this to exercise the validation path. + vi.spyOn(fetchStoryIndex, 'fetchStoryIndex').mockResolvedValue( + makeIndex(['button--primary', 'button--secondary', 'page--home']), + ); const adapter = new ValibotJsonSchemaAdapter(); server = new McpServer( { @@ -195,4 +217,83 @@ describe('displayReviewTool', () => { expect(result?.content?.[0]?.text).toMatch(/Cannot resolve the Storybook URL/); expect(emitted).toEqual([]); }); + + describe('story ID validation', () => { + it('rejects the whole review when any story ID is not in the live index', async () => { + // Two real IDs, two fabricated ones — the agent invented the + // "--default" exports based on naming conventions. + vi.spyOn(fetchStoryIndex, 'fetchStoryIndex').mockResolvedValue( + makeIndex(['button--primary', 'page--home']), + ); + const reviewWithFakes: ReviewState = { + ...sampleReview, + collections: [ + { + title: 'Button', + rationale: 'Real ID.', + storyIds: ['button--primary'], + }, + { + title: 'Sidebar', + rationale: 'Fabricated.', + storyIds: ['components-sidebar--default', 'components-modal--default'], + }, + { + title: 'Pages', + rationale: 'Real ID.', + storyIds: ['page--home'], + }, + ], + }; + const response = await callTool(reviewWithFakes, makeContext()); + const result = getResult(response); + + expect(result?.isError).toBe(true); + expect(result?.content?.[0]?.text).toContain('Refusing to publish review'); + expect(result?.content?.[0]?.text).toContain('components-sidebar--default'); + expect(result?.content?.[0]?.text).toContain('components-modal--default'); + expect(result?.content?.[0]?.text).toContain('get-stories-by-component'); + // Crucially, the channel emit must not have run — we don't want a + // partially-broken review to land on the review page. + expect(emitted).toEqual([]); + }); + + it('lists each unknown ID only once even if reused across collections', async () => { + vi.spyOn(fetchStoryIndex, 'fetchStoryIndex').mockResolvedValue(makeIndex([])); + const review: ReviewState = { + ...sampleReview, + collections: [ + { title: 'A', rationale: '.', storyIds: ['fake--one', 'fake--two'] }, + { title: 'B', rationale: '.', storyIds: ['fake--one'] }, + ], + }; + const response = await callTool(review, makeContext()); + const text = getResult(response)?.content?.[0]?.text ?? ''; + + // `fake--one` appears once in the listing (it's in a backtick-bullet + // like "- `fake--one`"), not twice. + const occurrences = text.match(/`fake--one`/g)?.length ?? 0; + expect(occurrences).toBe(1); + expect(text).toContain('`fake--two`'); + }); + + it('publishes normally when every ID resolves', async () => { + const response = await callTool(sampleReview, makeContext()); + const result = getResult(response); + + expect(result?.isError).toBeFalsy(); + expect(emitted).toEqual([{ event: PUSH_REVIEW_EVENT, payload: sampleReview }]); + }); + + it('skips the index fetch when there are no story IDs to validate', async () => { + const fetchSpy = vi.spyOn(fetchStoryIndex, 'fetchStoryIndex'); + const callCountBefore = fetchSpy.mock.calls.length; + const emptyReview: ReviewState = { + ...sampleReview, + collections: [{ title: 'Empty', rationale: '.', storyIds: [] }], + }; + await callTool(emptyReview, makeContext()); + expect(fetchSpy.mock.calls.length).toBe(callCountBefore); + }); + }); }); diff --git a/packages/addon-mcp/src/tools/display-review.ts b/packages/addon-mcp/src/tools/display-review.ts index 2ba2d23c..5ba3901d 100644 --- a/packages/addon-mcp/src/tools/display-review.ts +++ b/packages/addon-mcp/src/tools/display-review.ts @@ -2,6 +2,8 @@ import type { McpServer } from 'tmcp'; import * as v from 'valibot'; import type { AddonContext } from '../types.ts'; import { errorToMCPContent } from '../utils/errors.ts'; +import { fetchStoryIndex } from '../utils/fetch-story-index.ts'; +import { withFriendlyErrors } from '../utils/format-validation-issues.ts'; import { DEFAULT_MCP_ENDPOINT, PUSH_REVIEW_EVENT, REVIEW_PAGE_PATH } from '../constants.ts'; import { DISPLAY_REVIEW_TOOL_NAME } from './tool-names.ts'; @@ -94,6 +96,37 @@ export function buildReviewUrl(ctx: { return `${root.replace(/\/$/, '')}/?path=${REVIEW_PAGE_PATH}`; } +/** + * Returns the storyIds the agent passed that don't resolve against the live + * Storybook index. Order-preserving and deduplicated so the error message + * lists each fabricated ID once, in the order the agent provided them. + */ +async function collectUnknownStoryIds( + collections: ReadonlyArray<{ readonly storyIds: ReadonlyArray }>, + origin: string, +): Promise { + const requested = new Set(); + const inOrder: string[] = []; + for (const collection of collections) { + for (const id of collection.storyIds) { + if (!requested.has(id)) { + requested.add(id); + inOrder.push(id); + } + } + } + if (inOrder.length === 0) return []; + + const index = await fetchStoryIndex(origin); + return inOrder.filter((id) => !index.entries[id]); +} + +function formatUnknownStoryIdsError(unknownIds: string[]): string { + const list = unknownIds.map((id) => `- \`${id}\``).join('\n'); + const plural = unknownIds.length === 1 ? 'ID is' : 'IDs are'; + return `Refusing to publish review: ${unknownIds.length} story ${plural} not in the live Storybook index:\n${list}\n\nThis usually means the IDs were inferred from file paths or naming conventions rather than returned by a tool. Resolve real IDs by calling \`get-stories-by-component\` (for components you've edited or want covered) or \`list-all-documentation\` (to browse the index), then retry \`display-review\` with the verified IDs. Do not invent IDs to satisfy this check.`; +} + export async function addDisplayReviewTool(server: McpServer) { server.tool( { @@ -101,6 +134,8 @@ export async function addDisplayReviewTool(server: McpServer) title: 'Display Storybook review', description: `Push a curated review of the current change to Storybook's review page. +**Every storyId you pass here must have come from a tool result in this session** — \`get-changed-stories\`, \`get-stories-by-component\`, or \`list-all-documentation\`. IDs derived from file paths, story-file naming conventions, feature names, or memory will not resolve. The tool validates every ID against the live Storybook index and rejects the whole review if any are unknown, so guessing is a hard failure, not a soft one. If you don't have a verified ID for a story you want to include, call \`get-stories-by-component\` first. + After you finish a UI code change, call this to help the user spot-check it. Before composing collections, answer two questions: @@ -116,7 +151,7 @@ Provide: Anti-pattern: editing a theme token that only one component reads, then publishing a review with just that one component's story. The token change is visible on every page that renders the component — include those pages. Always include the returned reviewUrl in your final user-facing response so the user can open it. This tool maintains a single active review state; each call replaces the previously published review.`, - schema: ReviewStateSchema, + schema: withFriendlyErrors(ReviewStateSchema), outputSchema: DisplayReviewOutput, enabled: () => server.ctx.custom?.toolsets?.dev ?? true, }, @@ -129,6 +164,20 @@ Always include the returned reviewUrl in your final user-facing response so the ); } + // Validate every storyId against the live index before publishing. + // Without this gate, fabricated IDs (e.g. derived from filenames or + // naming conventions) make it into the review unchallenged — the + // agent gets a reviewUrl back, assumes success, and the user opens + // a broken page. Hard-failing here forces the agent to resolve + // real IDs via get-stories-by-component before retrying. + const unknownIds = await collectUnknownStoryIds( + input.collections, + customContext.origin, + ); + if (unknownIds.length > 0) { + throw new Error(formatUnknownStoryIdsError(unknownIds)); + } + const reviewUrl = buildReviewUrl({ origin: customContext.origin, request: customContext.request, diff --git a/packages/addon-mcp/src/tools/get-changed-stories.test.ts b/packages/addon-mcp/src/tools/get-changed-stories.test.ts index b749e0e3..2cf7d780 100644 --- a/packages/addon-mcp/src/tools/get-changed-stories.test.ts +++ b/packages/addon-mcp/src/tools/get-changed-stories.test.ts @@ -8,14 +8,34 @@ import smallStoryIndexFixture from '../../fixtures/small-story-index.fixture.jso import { GET_CHANGED_STORIES_TOOL_NAME } from './tool-names.ts'; import type { StoryIndex } from 'storybook/internal/types'; -const { mockGetStatusStore } = vi.hoisted(() => ({ - mockGetStatusStore: vi.fn(), -})); +const { mockGetStatusStore, mockGetActiveStoryDependencyGraphService, mockExecSync } = vi.hoisted( + () => ({ + mockGetStatusStore: vi.fn<(...args: any[]) => any>(), + // Defaults to "service inactive" — tests exercising the unreachable-files + // path override this with `mockGetActiveStoryDependencyGraphService.mockReturnValue`. + mockGetActiveStoryDependencyGraphService: vi.fn<(...args: any[]) => any>(), + // Hoisted because node:child_process is loaded inside + // detect-unreachable-changes.ts at module-eval time; ESM forbids + // retroactive vi.spyOn. + mockExecSync: vi.fn<(...args: any[]) => any>(), + }), +); vi.mock('storybook/internal/core-server', () => ({ experimental_getStatusStore: (...args: unknown[]) => mockGetStatusStore(...args), + // `get-changed-stories` calls this via `detectUnreachableChanges` to surface + // modified working-tree files that aren't reached from any story root. + experimental_getDependencyGraphService: (...args: unknown[]) => + mockGetActiveStoryDependencyGraphService(...args), })); +vi.mock('node:child_process', async () => { + const actual = await vi.importActual( + 'node:child_process', + ); + return { ...actual, execSync: (...args: unknown[]) => mockExecSync(...(args as [])) }; +}); + describe('getChangedStoriesTool', () => { let server: McpServer; const testContext: AddonContext = { @@ -26,6 +46,10 @@ describe('getChangedStoriesTool', () => { beforeEach(async () => { mockGetStatusStore.mockReset(); + mockGetActiveStoryDependencyGraphService.mockReset(); + mockGetActiveStoryDependencyGraphService.mockReturnValue(undefined); + mockExecSync.mockReset(); + mockExecSync.mockReturnValue(''); const adapter = new ValibotJsonSchemaAdapter(); server = new McpServer( { @@ -300,4 +324,87 @@ describe('getChangedStoriesTool', () => { expect(text).toBe('No new, modified, or related stories detected.'); expect(vi.mocked(fetchStoryIndex.fetchStoryIndex).mock.calls.length).toBe(callCountBefore); }); + + it('appends an unreachable-files hint to the empty response when the working tree has uncommitted source files outside the story graph', async () => { + // The "theme-token edit" case: the agent changed a file that isn't + // reached from any story root, so the status store is empty. Without + // this hint the agent reads "no impact" and stops — the original + // hallucination this whole feature exists to prevent. + mockGetStatusStore.mockReturnValue({ getAll: () => ({}) }); + mockGetActiveStoryDependencyGraphService.mockReturnValue({ + hasGraph: () => true, + // theme.ts is modified but no story file imports it. + lookup: (_dep: string) => new Map(), + }); + mockExecSync.mockReturnValue(' M src/styles/theme.ts\n'); + + const response = await callTool(); + const text = getResultText(response); + + expect(text).toContain('No new, modified, or related stories detected.'); + expect(text).toContain('src/styles/theme.ts'); + expect(text).toMatch(/unreachable/i); + expect(text).toContain('get-stories-by-component'); + }); + + it('front-loads a coverage-gap banner AND appends a sanity-check hint when results coexist with unreachable working-tree files', async () => { + // Belt-and-braces: long story lists (Chromatic-scale) can run past + // host-side tool-output truncation caps, dropping the trailing hint. + // The leading banner is the short, survivable salience aid; the tail + // hint stays for agents that read in full. + mockGetStatusStore.mockReturnValue({ + getAll: () => ({ + 'button--primary': { + 'storybook/change-detection': { + value: 'status-value:modified', + storyId: 'button--primary', + }, + }, + }), + }); + mockGetActiveStoryDependencyGraphService.mockReturnValue({ + hasGraph: () => true, + lookup: () => new Map(), + }); + mockExecSync.mockReturnValue(' M .storybook/main.ts\n M src/server.ts\n'); + + const response = await callTool(); + const text = getResultText(response); + + const bannerIdx = text.indexOf('Coverage gap'); + const headlineIdx = text.indexOf('Detected'); + expect(bannerIdx).toBeGreaterThanOrEqual(0); + expect(bannerIdx).toBeLessThan(headlineIdx); + expect(text).toContain('.storybook/main.ts'); + expect(text).toContain('src/server.ts'); + // And the long-form sanity-check hint still trails the bullet list, + // so agents that read in full get the explanatory paragraph. + expect(text).toMatch(/coverage sanity check/i); + }); + + it('omits both callouts when nothing in the working tree is unreachable', async () => { + mockGetStatusStore.mockReturnValue({ + getAll: () => ({ + 'button--primary': { + 'storybook/change-detection': { + value: 'status-value:modified', + storyId: 'button--primary', + }, + }, + }), + }); + mockGetActiveStoryDependencyGraphService.mockReturnValue({ + hasGraph: () => true, + // Every modified file IS in the graph — no unreachable callout fires. + lookup: () => new Map([['/repo/src/Button.stories.tsx', 1]]), + }); + mockExecSync.mockReturnValue(' M src/Button.tsx\n'); + + const response = await callTool(); + const text = getResultText(response); + + expect(text).not.toContain('Coverage gap'); + expect(text).not.toMatch(/coverage sanity check/i); + expect(text.startsWith('Detected')).toBe(true); + }); }); diff --git a/packages/addon-mcp/src/tools/get-changed-stories.ts b/packages/addon-mcp/src/tools/get-changed-stories.ts index 56c95f6e..689d9209 100644 --- a/packages/addon-mcp/src/tools/get-changed-stories.ts +++ b/packages/addon-mcp/src/tools/get-changed-stories.ts @@ -4,6 +4,12 @@ import { collectTelemetry } from '../telemetry.ts'; import type { AddonContext } from '../types.ts'; import { errorToMCPContent } from '../utils/errors.ts'; import { fetchStoryIndex } from '../utils/fetch-story-index.ts'; +import { + detectUnreachableChanges, + formatPartialCoverageBanner, + formatPartialCoverageHint, + formatUnreachableHint, +} from '../utils/detect-unreachable-changes.ts'; import { GET_CHANGED_STORIES_TOOL_NAME } from './tool-names.ts'; const CHANGE_DETECTION_TYPE = 'storybook/change-detection'; @@ -87,9 +93,20 @@ export async function addGetChangedStoriesTool(server: McpServer `- \`${storyId}\`: ${title} / ${name} (\`${importPath}\`)`; @@ -159,6 +185,8 @@ export async function addGetChangedStoriesTool(server: McpServer { + let server: McpServer; + const cwd = process.cwd(); + const testContext: AddonContext = { + origin: 'http://localhost:6006', + options: {} as AddonContext['options'], + disableTelemetry: true, + }; + + function mockLookup(response: ComponentStoriesResponse) { + vi.spyOn(componentStoriesModule, 'resolveComponentStories').mockResolvedValue(response); + } + + beforeEach(async () => { + const adapter = new ValibotJsonSchemaAdapter(); + server = new McpServer( + { + name: 'test-server', + version: '1.0.0', + description: 'Test server for get-stories-by-component tool', + }, + { + adapter, + capabilities: { tools: { listChanged: true } }, + }, + ).withContext(); + + await server.receive( + { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' }, + }, + }, + { sessionId: 'test-session' }, + ); + + await addGetStoriesByComponentTool(server); + vi.spyOn(fetchStoryIndex, 'fetchStoryIndex').mockResolvedValue( + smallStoryIndexFixture as unknown as StoryIndex, + ); + }); + + async function callTool(componentPaths: string[], maxDistance?: number) { + return server.receive( + { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/call', + params: { + name: GET_STORIES_BY_COMPONENT_TOOL_NAME, + arguments: { componentPaths, ...(maxDistance !== undefined ? { maxDistance } : {}) }, + }, + }, + { sessionId: 'test-session', custom: testContext }, + ); + } + + function getResult(response: unknown) { + return ( + response as { + result?: { + content?: Array<{ text?: string }>; + structuredContent?: unknown; + isError?: boolean; + }; + } + ).result; + } + + it('returns stories with their reverse-graph depth', async () => { + mockLookup({ + available: true, + workingDir: cwd, + results: [ + { + componentPath: `${cwd}/src/Button.tsx`, + matches: [ + { storyId: 'button--primary', depth: 1 }, + { storyId: 'button--secondary', depth: 1 }, + ], + }, + ], + }); + + const response = await callTool([`${cwd}/src/Button.tsx`]); + const result = getResult(response); + + expect(result?.content?.[0]?.text).toMatchInlineSnapshot(` + "${cwd}/src/Button.tsx: + → 2 stories across 1 component, distances 1..1 (d1=2) + distance 1: + - \`button--primary\`: Button / Primary (\`./src/Button.stories.tsx\`) + - \`button--secondary\`: Button / Secondary (\`./src/Button.stories.tsx\`)" + `); + expect(result?.structuredContent).toEqual({ + results: [ + { + componentPath: `${cwd}/src/Button.tsx`, + matches: [ + { + storyId: 'button--primary', + title: 'Button', + name: 'Primary', + importPath: './src/Button.stories.tsx', + distance: 1, + }, + { + storyId: 'button--secondary', + title: 'Button', + name: 'Secondary', + importPath: './src/Button.stories.tsx', + distance: 1, + }, + ], + }, + ], + }); + }); + + it('reports components with no stories', async () => { + mockLookup({ + available: true, + workingDir: cwd, + results: [{ componentPath: `${cwd}/src/Missing.tsx`, matches: [] }], + }); + + const response = await callTool([`${cwd}/src/Missing.tsx`]); + const result = getResult(response); + + expect(result?.content?.[0]?.text).toBe(`${cwd}/src/Missing.tsx: no stories found`); + }); + + it('handles multiple components in a single call', async () => { + mockLookup({ + available: true, + workingDir: cwd, + results: [ + { + componentPath: `${cwd}/src/Input.tsx`, + matches: [{ storyId: 'input--default', depth: 1 }], + }, + { componentPath: `${cwd}/src/Missing.tsx`, matches: [] }, + ], + }); + + const response = await callTool([`${cwd}/src/Input.tsx`, `${cwd}/src/Missing.tsx`]); + const text = getResult(response)?.content?.[0]?.text; + + expect(text).toContain('input--default'); + expect(text).toContain(`${cwd}/src/Missing.tsx: no stories found`); + }); + + it('rejects empty input', async () => { + const response = await callTool([]); + const result = getResult(response); + expect(result?.isError).toBe(true); + }); + + it('surfaces a typed error when the dependency graph is unavailable', async () => { + mockLookup({ + available: false, + reason: "Storybook's story dependency graph is unavailable.", + }); + + const response = await callTool([`${cwd}/src/Button.tsx`]); + const result = getResult(response); + expect(result?.isError).toBe(true); + expect(result?.content?.[0]?.text).toContain('dependency graph is unavailable'); + }); + + it('applies maxDistance and records clipped tail', async () => { + mockLookup({ + available: true, + workingDir: cwd, + results: [ + { + componentPath: `${cwd}/src/Button.tsx`, + matches: [ + { storyId: 'button--primary', depth: 1 }, + { storyId: 'button--secondary', depth: 1 }, + { storyId: 'input--default', depth: 3 }, + ], + }, + ], + }); + + const response = await callTool([`${cwd}/src/Button.tsx`], 1); + const text = getResult(response)?.content?.[0]?.text; + expect(text).toContain('+1 more story at distance 3 hidden by `maxDistance: 1`'); + }); +}); + +describe('serializeComponentSection', () => { + const m = ( + storyId: string, + title: string, + distance: number, + importPath = `./src/${title}.stories.tsx`, + ): ComponentStoryMatch => ({ storyId, title, name: storyId, importPath, distance }); + + it('groups matches by distance and prefixes a shape summary', () => { + const text = serializeComponentSection('/repo/src/Button.tsx', [ + m('button--primary', 'Button', 0, './src/Button.stories.tsx'), + m('modal--default', 'Modal', 1, './src/Modal.stories.tsx'), + m('page--default', 'Page', 2, './src/Page.stories.tsx'), + ]); + expect(text).toMatchInlineSnapshot(` + "/repo/src/Button.tsx: + → 3 stories across 3 components, distances 0..2 (d0=1, d1=1, d2=1) + distance 0: + - \`button--primary\`: Button / button--primary (\`./src/Button.stories.tsx\`) + distance 1: + - \`modal--default\`: Modal / modal--default (\`./src/Modal.stories.tsx\`) + distance 2: + - \`page--default\`: Page / page--default (\`./src/Page.stories.tsx\`)" + `); + }); + + it('reports no stories found when matches are empty', () => { + expect(serializeComponentSection('/repo/src/Missing.tsx', [])).toBe( + '/repo/src/Missing.tsx: no stories found', + ); + }); + + it('singularizes the summary when there is exactly one match in one component', () => { + const text = serializeComponentSection('/repo/src/Button.tsx', [m('button--primary', 'Button', 0)]); + expect(text).toContain('→ 1 story across 1 component, distances 0..0 (d0=1)'); + }); + + it('distinguishes "no stories within maxDistance" from "no stories found"', () => { + const text = serializeComponentSection('/repo/src/lib/apolloCacheUtils.ts', [], { + maxDistance: 2, + clipped: { count: 1212, distances: [3, 4] }, + }); + expect(text).toBe( + '/repo/src/lib/apolloCacheUtils.ts: no stories within `maxDistance: 2` — +1212 more stories at distances 3..4 hidden by `maxDistance: 2`.', + ); + }); + + it('appends a clipped-tail line when some matches passed the cap and others did not', () => { + const text = serializeComponentSection( + '/repo/src/Button.tsx', + [m('button--primary', 'Button', 0), m('modal--default', 'Modal', 1)], + { maxDistance: 1, clipped: { count: 42, distances: [2, 3, 4] } }, + ); + expect(text).toContain('+42 more stories at distances 2..4 hidden by `maxDistance: 1`'); + }); + + it('emits singular phrasing when exactly one match was clipped', () => { + const text = serializeComponentSection( + '/repo/src/Lib.ts', + [m('a--default', 'A', 0)], + { maxDistance: 1, clipped: { count: 1, distances: [2] } }, + ); + expect(text).toContain('+1 more story at distance 2 hidden by `maxDistance: 1`'); + }); + + it('does not emit the clipped tail when nothing was clipped', () => { + const text = serializeComponentSection( + '/repo/src/Button.tsx', + [m('button--primary', 'Button', 0)], + { maxDistance: 2 }, + ); + expect(text).not.toContain('hidden by'); + }); +}); diff --git a/packages/addon-mcp/src/tools/get-stories-by-component.ts b/packages/addon-mcp/src/tools/get-stories-by-component.ts new file mode 100644 index 00000000..f3a021ec --- /dev/null +++ b/packages/addon-mcp/src/tools/get-stories-by-component.ts @@ -0,0 +1,326 @@ +import type { McpServer } from 'tmcp'; +import * as v from 'valibot'; +import type { StoryIndex } from 'storybook/internal/types'; +import { collectTelemetry } from '../telemetry.ts'; +import { errorToMCPContent } from '../utils/errors.ts'; +import { fetchStoryIndex } from '../utils/fetch-story-index.ts'; +import { + resolveComponentStories, + type ComponentStoryDepth, +} from '../utils/resolve-component-stories.ts'; +import type { AddonContext } from '../types.ts'; +import { + GET_CHANGED_STORIES_TOOL_NAME, + GET_STORIES_BY_COMPONENT_TOOL_NAME, + PREVIEW_STORIES_TOOL_NAME, +} from './tool-names.ts'; + +/** + * Memoise the story index per origin for a brief window. Agents commonly fire several tool + * calls back-to-back within a single turn; without this each call re-hits `/index.json` over + * loopback HTTP. TTL is short so HMR-added stories appear quickly on the next turn. + */ +const STORY_INDEX_CACHE_TTL_MS = 2000; +const storyIndexCache = new Map }>(); + +function getStoryIndexCached(origin: string): Promise { + const now = Date.now(); + const cached = storyIndexCache.get(origin); + if (cached && now - cached.fetchedAt < STORY_INDEX_CACHE_TTL_MS) return cached.index; + const index = fetchStoryIndex(origin); + // Drop the cache entry if the fetch rejects, so the next caller retries instead of inheriting + // the rejection forever. + index.catch(() => storyIndexCache.delete(origin)); + storyIndexCache.set(origin, { fetchedAt: now, index }); + return index; +} + +/** When omitted by the caller, applied internally to keep result sets actionable on real codebases. */ +const DEFAULT_MAX_DISTANCE = 3; + +const GetStoriesByComponentInput = v.object({ + componentPaths: v.pipe( + v.array(v.string()), + v.minLength(1), + v.description( + `Absolute paths to component source files (e.g. "/repo/src/Button.tsx"). +Pass the components you actually want stories for — typically files you just read, edited, or that the user mentioned. +Story files (\`*.stories.*\`) are accepted too: they appear at distance 0 as self-matches, plus any reverse-graph hits (other stories that import them).`, + ), + ), + maxDistance: v.pipe( + v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))), + v.description( + `Ceiling on the import depth to include in results. Must be a positive integer. +- 1: only stories that directly import the component. +- 2+: also include stories that reach the component through N hops. +Defaults to ${DEFAULT_MAX_DISTANCE}; raise it to widen recall, lower it to tighten precision. Shared components (Button, Icon, …) accumulate noisy indirect matches at distance ≥ 3, so the default cap protects against runaway results.`, + ), + ), +}); + +const StoryMatch = v.object({ + storyId: v.string(), + title: v.string(), + name: v.string(), + importPath: v.string(), + distance: v.pipe( + v.number(), + v.description( + 'Import-graph depth from the story file to the component (lower = stronger). 1: story file directly imports the component. 2+: reached through N hops.', + ), + ), +}); + +const ClippedByMaxDistanceSchema = v.pipe( + v.object({ + count: v.number(), + distances: v.array(v.number()), + }), + v.description( + 'Present only when `maxDistance` filtered out one or more matches. `count` is how many were dropped; `distances` lists the (sorted, distinct) distances those dropped matches sat at — widen `maxDistance` to include them.', + ), +); + +const GetStoriesByComponentOutput = v.object({ + results: v.array( + v.object({ + componentPath: v.string(), + matches: v.array(StoryMatch), + clipped: v.optional(ClippedByMaxDistanceSchema), + pathNotFound: v.pipe( + v.optional(v.boolean()), + v.description( + '`true` when no file exists at the resolved absolute path. Distinguishes a typo from "this component has no stories yet". The agent should re-check the path it sent.', + ), + ), + }), + ), +}); + +export type GetStoriesByComponentOutput = v.InferOutput; + +export interface ComponentStoryMatch { + storyId: string; + title: string; + name: string; + importPath: string; + distance: number; +} + +export interface ClippedByMaxDistance { + count: number; + distances: number[]; +} + +interface ComponentMatchResult { + componentPath: string; + matches: ComponentStoryMatch[]; + clipped?: ClippedByMaxDistance; + pathNotFound?: boolean; +} + +function serializeMatch(match: ComponentStoryMatch) { + return ` - \`${match.storyId}\`: ${match.title} / ${match.name} (\`${match.importPath}\`)`; +} + +function pluralize(n: number, singular: string, plural = `${singular}s`): string { + return n === 1 ? singular : plural; +} + +export interface SerializeOptions { + maxDistance?: number; + clipped?: ClippedByMaxDistance; + pathNotFound?: boolean; +} + +function formatClippedTail(clipped: ClippedByMaxDistance, maxDistance: number): string { + const dists = clipped.distances; + const rangeText = + dists.length === 1 + ? `distance ${dists[0]}` + : `distances ${dists[0]}..${dists[dists.length - 1]}`; + return `+${clipped.count} more ${pluralize(clipped.count, 'story', 'stories')} at ${rangeText} hidden by \`maxDistance: ${maxDistance}\``; +} + +export function serializeComponentSection( + componentPath: string, + matches: ComponentStoryMatch[], + options: SerializeOptions = {}, +): string { + const { maxDistance, clipped, pathNotFound } = options; + + if (pathNotFound) { + return `${componentPath}: path does not exist on disk — re-check the path you sent.`; + } + + // Distinguish "genuinely no stories" from "the cap filtered everything out". + // Same surface text either way, but readers must be able to act on it. + if (matches.length === 0) { + if (clipped && clipped.count > 0 && maxDistance !== undefined) { + return `${componentPath}: no stories within \`maxDistance: ${maxDistance}\` — ${formatClippedTail(clipped, maxDistance)}.`; + } + return `${componentPath}: no stories found`; + } + + const byDistance = new Map(); + for (const m of matches) { + const bucket = byDistance.get(m.distance) ?? []; + bucket.push(m); + byDistance.set(m.distance, bucket); + } + + const distances = [...byDistance.keys()].sort((a, b) => a - b); + const minDist = distances[0]!; + const maxDist = distances[distances.length - 1]!; + const componentCount = new Set(matches.map((m) => m.title)).size; + const bucketSummary = distances.map((d) => `d${d}=${byDistance.get(d)!.length}`).join(', '); + const summary = `→ ${matches.length} ${pluralize(matches.length, 'story', 'stories')} across ${componentCount} ${pluralize(componentCount, 'component')}, distances ${minDist}..${maxDist} (${bucketSummary})`; + + const lines: string[] = [`${componentPath}:`, summary]; + for (const d of distances) { + lines.push(`distance ${d}:`); + for (const m of byDistance.get(d)!) lines.push(serializeMatch(m)); + } + + if (clipped && clipped.count > 0 && maxDistance !== undefined) { + lines.push(` (${formatClippedTail(clipped, maxDistance)}.)`); + } + + return lines.join('\n'); +} + +function applyMaxDistance( + depths: ComponentStoryDepth[], + maxDistance: number | undefined, +): { kept: ComponentStoryDepth[]; clipped?: ClippedByMaxDistance } { + if (maxDistance === undefined) return { kept: depths }; + const kept: ComponentStoryDepth[] = []; + const clippedDistances = new Set(); + let clippedCount = 0; + for (const d of depths) { + if (d.depth <= maxDistance) kept.push(d); + else { + clippedCount++; + clippedDistances.add(d.depth); + } + } + const clipped = + clippedCount > 0 + ? { + count: clippedCount, + distances: [...clippedDistances].sort((a, b) => a - b), + } + : undefined; + return { kept, clipped }; +} + +export async function addGetStoriesByComponentTool(server: McpServer) { + server.tool( + { + name: GET_STORIES_BY_COMPONENT_TOOL_NAME, + title: 'Get stories for component files', + description: `Map component source files to the stories that render them, so you can hand real story IDs to ${PREVIEW_STORIES_TOOL_NAME} instead of guessing. + +**When to use this vs \`${GET_CHANGED_STORIES_TOOL_NAME}\`:** if the user just edited code, call \`${GET_CHANGED_STORIES_TOOL_NAME}\` first — it reads Storybook's live git-diff signal for free. Only call this tool when you need to map specific file paths to stories: the user described a feature/area by name, \`${GET_CHANGED_STORIES_TOOL_NAME}\` returned nothing (the change is outside the story graph and you need to find runtime consumers yourself), or it returned too much and you need to narrow. + +**Use this whenever the user describes a part of the UI by feature, area, or topic** ("review the credit-card components", "preview every checkout story", "show me what cart looks like", "stories related to authentication") — first locate the relevant component files in the repo (grep/Glob), then pass their absolute paths here. The tool returns grounded \`storyId\` values from the live Storybook index; never invent IDs from file names, feature names, or memory. + +Returns sorted results from the Storybook index — if a component has no matches here, it likely has no stories yet (say so, don't fabricate). + +Backed by Storybook's live reverse dependency graph: distance is the import-graph hop count from the story file to the component (1 = directly imported, 2+ = transitively). Available when the Storybook dev server is running with a builder that supports change detection (e.g. Vite); otherwise the tool returns a typed error. + +Results are sorted by \`distance\` (lower = stronger signal). Prefer the lowest-distance results first; widen only when needed. For shared components like Button or Icon, expect many indirect (\`distance\` ≥ 2) matches — pass \`maxDistance\` to cap noise.`, + schema: GetStoriesByComponentInput, + outputSchema: GetStoriesByComponentOutput, + enabled: () => server.ctx.custom?.toolsets?.dev ?? true, + }, + async (input) => { + try { + const { origin, disableTelemetry } = server.ctx.custom ?? {}; + if (!origin) { + throw new Error('Origin is required in addon context'); + } + + const index = await getStoryIndexCached(origin); + const lookup = await resolveComponentStories( + { componentPaths: input.componentPaths }, + { getStoryIndex: async () => index }, + ); + + if (!lookup.available) { + return { + content: [ + { + type: 'text' as const, + text: + lookup.reason ?? + "Storybook's story dependency graph is unavailable. Make sure the dev server is running with a builder that supports change detection.", + }, + ], + isError: true, + }; + } + + const effectiveMaxDistance = input.maxDistance ?? DEFAULT_MAX_DISTANCE; + + const results: ComponentMatchResult[] = (lookup.results ?? []).map((entry) => { + if (entry.pathNotFound) { + return { componentPath: entry.componentPath, matches: [], pathNotFound: true }; + } + const { kept, clipped } = applyMaxDistance(entry.matches, effectiveMaxDistance); + const matches: ComponentStoryMatch[] = []; + for (const { storyId, depth } of kept) { + const indexEntry = index.entries[storyId]; + if (!indexEntry || indexEntry.type !== 'story') continue; + matches.push({ + storyId: indexEntry.id, + title: indexEntry.title, + name: indexEntry.name, + importPath: indexEntry.importPath, + distance: depth, + }); + } + return { componentPath: entry.componentPath, matches, clipped }; + }); + + const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0); + const unmatchedCount = results.filter( + (r) => !r.pathNotFound && r.matches.length === 0, + ).length; + + const textSections = results.map(({ componentPath, matches, clipped, pathNotFound }) => + serializeComponentSection(componentPath, matches, { + maxDistance: effectiveMaxDistance, + clipped, + pathNotFound, + }), + ); + + const text = + textSections.length > 0 + ? textSections.join('\n\n') + : 'No component paths provided.'; + + if (!disableTelemetry) { + await collectTelemetry({ + event: 'tool:getStoriesByComponent', + server, + toolset: 'dev', + componentCount: input.componentPaths.length, + matchedComponentCount: input.componentPaths.length - unmatchedCount, + totalMatchCount: totalMatches, + maxDistance: effectiveMaxDistance, + }); + } + + return { + content: [{ type: 'text' as const, text }], + structuredContent: { results }, + }; + } catch (error) { + return errorToMCPContent(error); + } + }, + ); +} diff --git a/packages/addon-mcp/src/tools/tool-names.ts b/packages/addon-mcp/src/tools/tool-names.ts index 6d87dd13..4d20527a 100644 --- a/packages/addon-mcp/src/tools/tool-names.ts +++ b/packages/addon-mcp/src/tools/tool-names.ts @@ -4,6 +4,7 @@ export const PREVIEW_STORIES_TOOL_NAME = 'preview-stories'; export const GET_CHANGED_STORIES_TOOL_NAME = 'get-changed-stories'; +export const GET_STORIES_BY_COMPONENT_TOOL_NAME = 'get-stories-by-component'; export const GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME = 'get-storybook-story-instructions'; export const RUN_STORY_TESTS_TOOL_NAME = 'run-story-tests'; export const DISPLAY_REVIEW_TOOL_NAME = 'display-review'; diff --git a/packages/addon-mcp/src/utils/change-detection.ts b/packages/addon-mcp/src/utils/change-detection.ts new file mode 100644 index 00000000..5c9b7941 --- /dev/null +++ b/packages/addon-mcp/src/utils/change-detection.ts @@ -0,0 +1,41 @@ +/** + * Shim around `experimental_getDependencyGraphService`. Older Storybook versions + * don't ship it; we dynamically import and treat a missing export as "unsupported". + */ + +export interface StoryDependencyGraphService { + lookup(dep: string): Map; + hasGraph(): boolean; +} + +type GetActiveServiceFn = () => StoryDependencyGraphService | undefined; + +let probed: GetActiveServiceFn | null | undefined; + +async function probe(): Promise { + if (probed !== undefined) return probed; + try { + const mod = (await import('storybook/internal/core-server')) as Record; + const fn = mod.experimental_getDependencyGraphService; + probed = typeof fn === 'function' ? (fn as GetActiveServiceFn) : null; + } catch { + probed = null; + } + return probed; +} + +/** True iff the loaded Storybook ships the graph API. */ +export async function isDependencyGraphSupported(): Promise { + return (await probe()) !== null; +} + +/** + * Returns the active graph, or undefined if Storybook doesn't ship the API, or the dev-server + * hasn't started it yet. A non-undefined result doesn't mean the graph is built — call `hasGraph()`. + */ +export async function getDependencyGraphService(): Promise< + StoryDependencyGraphService | undefined +> { + const fn = await probe(); + return fn ? fn() : undefined; +} diff --git a/packages/addon-mcp/src/utils/detect-unreachable-changes.test.ts b/packages/addon-mcp/src/utils/detect-unreachable-changes.test.ts new file mode 100644 index 00000000..3ecb08a7 --- /dev/null +++ b/packages/addon-mcp/src/utils/detect-unreachable-changes.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + formatPartialCoverageBanner, + formatPartialCoverageHint, + formatUnreachableHint, +} from './detect-unreachable-changes.ts'; + +describe('formatUnreachableHint', () => { + it('returns empty string when there are no unreachable files', () => { + expect(formatUnreachableHint([])).toBe(''); + }); + + it('lists files and tells the agent what to do next', () => { + const out = formatUnreachableHint(['src/styles/theme.ts', 'src/utils/format.ts']); + expect(out).toContain('src/styles/theme.ts'); + expect(out).toContain('src/utils/format.ts'); + expect(out).toMatch(/unreachable/i); + expect(out).toContain('get-stories-by-component'); + expect(out).toMatch(/grep/i); + }); +}); + +describe('formatPartialCoverageHint', () => { + it('returns empty string when there are no unreachable files', () => { + expect(formatPartialCoverageHint([])).toBe(''); + }); + + it('warns about partial coverage and points at get-stories-by-component', () => { + const out = formatPartialCoverageHint(['src/styles/theme.ts']); + expect(out).toContain('src/styles/theme.ts'); + expect(out).toMatch(/coverage sanity check/i); + expect(out).toContain('get-stories-by-component'); + expect(out).toMatch(/never invent/i); + }); + + it('uses different framing from the empty-response hint', () => { + const partial = formatPartialCoverageHint(['src/styles/theme.ts']); + const empty = formatUnreachableHint(['src/styles/theme.ts']); + // The partial-coverage case is specifically about stale-but-non-empty + // responses; the empty-response case is about no results at all. The + // hints must be distinguishable so the agent reacts differently. + expect(partial).not.toBe(empty); + expect(partial).toMatch(/stale/i); + }); +}); + +describe('formatPartialCoverageBanner', () => { + it('returns empty string when there are no unreachable files', () => { + expect(formatPartialCoverageBanner([])).toBe(''); + }); + + it('inlines the file list when 3 or fewer files are flagged', () => { + const out = formatPartialCoverageBanner(['.storybook/main.ts', 'src/server.ts']); + expect(out).toMatch(/^⚠ Coverage gap:/); + expect(out).toContain('2 modified files'); + expect(out).toContain('.storybook/main.ts'); + expect(out).toContain('src/server.ts'); + expect(out).toContain('full sanity-check note at end'); + expect(out.endsWith('\n\n')).toBe(true); + }); + + it('summarises overflow as `+N more` past the inline limit', () => { + const out = formatPartialCoverageBanner(['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts']); + // First three inlined, remainder collapsed. + expect(out).toContain('a.ts, b.ts, c.ts, +2 more'); + expect(out).toContain('5 modified files'); + }); + + it('singularises when exactly one file is unreachable', () => { + const out = formatPartialCoverageBanner(['src/server.ts']); + expect(out).toContain('1 modified file unreachable'); + }); + + it('is short enough to survive realistic tool-output truncation', () => { + // The whole point of the banner is to outlast aggressive truncation / + // compaction passes. Five-file case with realistic-length paths is the + // upper bound we care about; cap at 250 chars so the leading line stays + // in the "definitely-not-dropped" budget of every host we've seen. + const out = formatPartialCoverageBanner([ + '.storybook/main.ts', + 'services/webapp/server.ts', + 'services/webapp/lib/auth.ts', + 'services/webapp/lib/cache.ts', + 'services/webapp/lib/feature-flags.ts', + ]); + expect(out.length).toBeLessThanOrEqual(250); + }); +}); + +describe('detectUnreachableChanges', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.resetModules(); + vi.doUnmock('storybook/internal/core-server'); + vi.doUnmock('node:child_process'); + }); + + function mockService(opts: { lookup: (dep: string) => Map }) { + vi.doMock('storybook/internal/core-server', () => ({ + experimental_getDependencyGraphService: () => ({ + hasGraph: () => true, + lookup: opts.lookup, + }), + })); + } + + function mockGit(porcelain: string) { + vi.doMock('node:child_process', () => ({ + execSync: () => porcelain, + })); + } + + it('lists working-tree files that the reverse-graph does not reach', async () => { + mockService({ + // reverse-graph knows about Badge.tsx but NOT theme.ts + lookup: (dep: string) => + dep.endsWith('Badge.tsx') + ? new Map([['/repo/src/Badge.stories.tsx', 1]]) + : new Map(), + }); + mockGit(' M src/styles/theme.ts\n M src/components/Badge/Badge.tsx\n'); + const { detectUnreachableChanges } = await import('./detect-unreachable-changes.ts'); + expect(await detectUnreachableChanges()).toEqual(['src/styles/theme.ts']); + }); + + it('returns [] when every modified file IS in the graph', async () => { + mockService({ + lookup: () => new Map([['/repo/src/x.stories.tsx', 1]]), + }); + mockGit(' M src/components/Badge/Badge.tsx\n'); + const { detectUnreachableChanges } = await import('./detect-unreachable-changes.ts'); + expect(await detectUnreachableChanges()).toEqual([]); + }); + + it('returns [] when the service is not active', async () => { + vi.doMock('storybook/internal/core-server', () => ({ + experimental_getDependencyGraphService: () => undefined, + })); + mockGit(' M src/styles/theme.ts\n'); + const { detectUnreachableChanges } = await import('./detect-unreachable-changes.ts'); + expect(await detectUnreachableChanges()).toEqual([]); + }); + + it('returns [] on older Storybook versions that lack the export', async () => { + // Backwards-compat: the named export is missing entirely. + vi.doMock('storybook/internal/core-server', () => ({})); + mockGit(' M src/styles/theme.ts\n'); + const { detectUnreachableChanges } = await import('./detect-unreachable-changes.ts'); + expect(await detectUnreachableChanges()).toEqual([]); + }); + + it('returns [] when the working tree is clean', async () => { + mockService({ lookup: () => new Map() }); + mockGit(''); + const { detectUnreachableChanges } = await import('./detect-unreachable-changes.ts'); + expect(await detectUnreachableChanges()).toEqual([]); + }); + + it('ignores non-source files (css, json, lockfiles, …)', async () => { + mockService({ lookup: () => new Map() }); + mockGit(' M src/styles/theme.css\n M package-lock.json\n M src/styles/theme.ts\n'); + const { detectUnreachableChanges } = await import('./detect-unreachable-changes.ts'); + expect(await detectUnreachableChanges()).toEqual(['src/styles/theme.ts']); + }); + + it('handles rename lines (`R old -> new`) by keeping the new path', async () => { + mockService({ lookup: () => new Map() }); + mockGit('R src/old.ts -> src/new.ts\n'); + const { detectUnreachableChanges } = await import('./detect-unreachable-changes.ts'); + expect(await detectUnreachableChanges()).toEqual(['src/new.ts']); + }); + + it('returns [] gracefully when git fails (not a repo, etc.)', async () => { + mockService({ lookup: () => new Map() }); + vi.doMock('node:child_process', () => ({ + execSync: () => { + throw new Error('not a git repository'); + }, + })); + const { detectUnreachableChanges } = await import('./detect-unreachable-changes.ts'); + expect(await detectUnreachableChanges()).toEqual([]); + }); + + it('caps the list at maxFiles to keep the agent response small', async () => { + mockService({ lookup: () => new Map() }); + const many = Array.from({ length: 50 }, (_, i) => ` M src/f${i}.ts\n`).join(''); + mockGit(many); + const { detectUnreachableChanges } = await import('./detect-unreachable-changes.ts'); + expect(await detectUnreachableChanges(5)).toHaveLength(5); + }); +}); diff --git a/packages/addon-mcp/src/utils/detect-unreachable-changes.ts b/packages/addon-mcp/src/utils/detect-unreachable-changes.ts new file mode 100644 index 00000000..b994f87e --- /dev/null +++ b/packages/addon-mcp/src/utils/detect-unreachable-changes.ts @@ -0,0 +1,116 @@ +import { execSync } from 'node:child_process'; +import path from 'node:path'; +import { getDependencyGraphService } from './change-detection.ts'; + +const SOURCE_EXT_RE = /\.(?:tsx?|jsx?|mjs|cjs)$/i; + +/** + * git status --porcelain produces lines like `XY filename`, where XY are two + * status characters (M, A, ?, etc). The pattern is rigid; we slice the prefix. + * Rename lines are formatted as `R old -> new` — keep only the new path. + */ +function parsePorcelain(output: string): string[] { + return output + .split('\n') + .filter(Boolean) + .map((line) => { + const rest = line.slice(3); // strip "XY " + const arrow = rest.indexOf(' -> '); + return arrow >= 0 ? rest.slice(arrow + 4).trim() : rest.trim(); + }); +} + +/** + * Lists working-tree modified files that are NOT reached from any story file + * via Storybook's reverse dependency graph. This is the "your edit isn't in + * the graph; you'll need to grep" case — typical for theme tokens, decorator + * config, and other infrastructure files consumed via Storybook's preview + * runtime rather than story-file imports. + * + * Returns workspace-relative paths, suitable for inlining into a tool + * response. Empty when: + * - change detection is inactive, + * - the working tree is clean, + * - or every modified file IS in the graph (the caller should use the + * status-store result directly). + */ +export async function detectUnreachableChanges(maxFiles = 10): Promise { + const service = await getDependencyGraphService(); + if (!service || !service.hasGraph()) return []; + // Matches what the dev server uses, so paths line up with the reverse-index keys. + const workingDir = process.cwd(); + + let porcelain: string; + try { + porcelain = execSync('git status --porcelain', { + cwd: workingDir, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return []; + } + + const relFiles = parsePorcelain(porcelain).filter((f) => SOURCE_EXT_RE.test(f)); + if (relFiles.length === 0) return []; + + const unreachable: string[] = []; + for (const rel of relFiles) { + const abs = path.resolve(workingDir, rel); + const hits = service.lookup(abs); + if (hits.size === 0) unreachable.push(rel); + if (unreachable.length >= maxFiles) break; + } + return unreachable; +} + +/** + * Maximum unreachable files listed inline in the short header banner. Beyond + * this we summarise the overflow as `+N more` to keep the banner one-line-ish. + */ +const BANNER_INLINE_LIMIT = 3; + +/** + * Short "front-loaded" version of {@link formatPartialCoverageHint}, designed + * to survive truncation and compaction. The full hint still trails the + * response body unchanged — this banner is purely a salience aid for the + * non-empty case where 1000+ story bullets would otherwise bury the tail. + * + * Empty when no unreachable files were detected. + */ +export function formatPartialCoverageBanner(unreachable: string[]): string { + if (unreachable.length === 0) return ''; + const fileList = + unreachable.length <= BANNER_INLINE_LIMIT + ? unreachable.join(', ') + : `${unreachable.slice(0, BANNER_INLINE_LIMIT).join(', ')}, +${unreachable.length - BANNER_INLINE_LIMIT} more`; + const noun = unreachable.length === 1 ? 'file' : 'files'; + return `⚠ Coverage gap: ${unreachable.length} modified ${noun} unreachable from any story (${fileList}) — full sanity-check note at end of this response.\n\n`; +} + +/** + * Formats the unreachable-files list into a hint appended to + * `get-changed-stories`' empty response. Empty string when no hint applies. + */ +export function formatUnreachableHint(unreachable: string[]): string { + if (unreachable.length === 0) return ''; + const lines = unreachable.map((f) => `- ${f}`).join('\n'); + return `\n\nThe following working-tree file(s) are modified but unreachable from any story (no static import path connects them — they are likely theme tokens, decorators, or other Storybook-preview-runtime files):\n${lines}\n\nFor these, grep the codebase for their exports (e.g. specific tokens or symbols) to find runtime consumers, then call \`get-stories-by-component\` with those consumer file paths.`; +} + +/** + * Formats a sanity-check hint for the *non-empty* response case: there ARE + * changed stories, but the working tree also contains modified files that + * aren't reachable from any story (typically because the diff bundles a prior + * sub-change on a component with a follow-up edit to shared infrastructure). + * In that situation, the changed-stories list is real but incomplete w.r.t. + * the agent's most recent sub-edit — the agent has to actively check coverage + * rather than trust the list. + * + * Empty string when no hint applies. + */ +export function formatPartialCoverageHint(unreachable: string[]): string { + if (unreachable.length === 0) return ''; + const lines = unreachable.map((f) => `- ${f}`).join('\n'); + return `\n\nCoverage sanity check: the working tree also contains modified file(s) that aren't reachable from any story above (no static import path connects them — typically theme tokens, decorators, or other preview-runtime files):\n${lines}\n\nThe list above is real but may be stale w.r.t. these files — they're often left over from an earlier sub-change in the same diff. Before composing a review, grep the codebase for their exports and call \`get-stories-by-component\` with the runtime consumers' file paths. Do not assume the list above already covers them, and never invent story IDs to fill the gap.`; +} diff --git a/packages/addon-mcp/src/utils/format-validation-issues.test.ts b/packages/addon-mcp/src/utils/format-validation-issues.test.ts new file mode 100644 index 00000000..f82b5d83 --- /dev/null +++ b/packages/addon-mcp/src/utils/format-validation-issues.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import * as v from 'valibot'; +import { withFriendlyErrors } from './format-validation-issues.ts'; + +const Collection = v.object({ + title: v.string(), + rationale: v.string(), + storyIds: v.array(v.string()), +}); + +const ReviewState = v.object({ + title: v.string(), + description: v.string(), + collections: v.array(Collection), +}); + +function runValidate(schema: any, input: unknown) { + const result = schema['~standard'].validate(input); + if (result instanceof Promise) throw new Error('not async in these tests'); + return result; +} + +describe('withFriendlyErrors', () => { + const friendly = withFriendlyErrors(ReviewState); + + it('passes valid input through unchanged', () => { + const ok = runValidate(friendly, { + title: 'x', + description: 'y', + collections: [{ title: 'a', rationale: 'b', storyIds: ['c'] }], + }); + expect((ok as any).value).toBeTruthy(); + expect((ok as any).issues).toBeUndefined(); + }); + + it('rewrites missing-required-field issues to a short message', () => { + // This is exactly the shape we got hit by in the live trial — collection + // missing `rationale`. + const result = runValidate(friendly, { + title: 'x', + description: 'y', + collections: [{ title: 'a', storyIds: ['c'] }], + }) as { issues: Array<{ path: string; message: string }> }; + const missing = result.issues.find((i) => i.message.includes('rationale')); + expect(missing).toBeDefined(); + expect(missing!.message).toMatch(/^Missing required field `rationale`/); + expect(missing!.path).toContain('collections'); + expect(missing!.path).toMatch(/\[0\]/); + }); + + it('points the "at" suffix at the parent container, not the missing field itself', () => { + const result = runValidate(friendly, { + title: 'x', + description: 'y', + collections: [{ title: 'a', storyIds: ['c'] }], + }) as { issues: Array<{ path: string; message: string }> }; + const missing = result.issues.find((i) => i.message.includes('rationale'))!; + // Bad: "Missing required field `rationale` at `collections[0].rationale`." + // Good: "Missing required field `rationale` at `collections[0]`." + expect(missing.message).toContain('at `collections[0]`'); + expect(missing.message).not.toContain('collections[0].rationale`.'); + }); + + it('omits the "at" suffix for top-level missing fields', () => { + const result = runValidate(friendly, { + // missing `title` entirely at the top + description: 'y', + collections: [], + }) as { issues: Array<{ path: string; message: string }> }; + const missing = result.issues.find((i) => i.message.includes('title'))!; + expect(missing.message).toMatch(/^Missing required field `title`\.$/); + }); + + it('strips bulky valibot metadata (`input`, `requirement`, `lang`, …)', () => { + const result = runValidate(friendly, { + title: 'x', + description: 'y', + collections: [{ title: 'a', storyIds: ['c'] }], + }) as { issues: Array> }; + // Only keys we expect on a friendly issue: + for (const issue of result.issues) { + expect(Object.keys(issue).sort()).toEqual(['message', 'path']); + } + }); + + it('produces issues that JSON.stringify to a short string (smoke check)', () => { + const result = runValidate(friendly, { + title: 'x', + description: 'y', + collections: [{ title: 'a', storyIds: ['c'] }], + }) as { issues: unknown }; + const json = JSON.stringify(result.issues); + // Sanity bound: the raw valibot dump for this case is ~3 KB. The friendly + // envelope should fit comfortably under 500 chars. + expect(json.length).toBeLessThan(500); + }); + + it('preserves the non-missing-key issues with their original message', () => { + // Wrong type rather than missing key. + const result = runValidate(friendly, { + title: 'x', + description: 'y', + collections: [{ title: 'a', rationale: 'b', storyIds: 'not-an-array' }], + }) as { issues: Array<{ path: string; message: string }> }; + expect(result.issues.length).toBeGreaterThan(0); + const wrongType = result.issues.find((i) => i.path.includes('storyIds')); + expect(wrongType).toBeDefined(); + // Original valibot message — not rewritten by the missing-key heuristic. + expect(wrongType!.message).not.toMatch(/^Missing required field/); + }); +}); diff --git a/packages/addon-mcp/src/utils/format-validation-issues.ts b/packages/addon-mcp/src/utils/format-validation-issues.ts new file mode 100644 index 00000000..0ed73132 --- /dev/null +++ b/packages/addon-mcp/src/utils/format-validation-issues.ts @@ -0,0 +1,112 @@ +/** + * Wraps a standard-schema validator so tmcp's "Invalid arguments for tool …" + * error contains a short, agent-readable issue list instead of the raw + * valibot dump. + * + * tmcp renders validation failures as `JSON.stringify(issues)`, so the wrapper + * has to return shortened *issue objects*, not strings. Each kept issue is + * trimmed to `{ path, message }` (we drop `input`, `requirement`, `lang`, + * `abortEarly`, `abortPipeEarly`, and other metadata that confuses agents), + * and we rewrite the common "missing required key" case into a flat human + * sentence. + */ + +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +// Shadow the spec's `Issue` shape with one that includes the fields valibot +// adds (`kind`, `type`, `expected`, `received`, …) so {@link summarizeIssue} +// can introspect them. The structural compatibility with the spec is what +// matters at the boundary — see {@link withFriendlyErrors}' generic constraint. +interface StandardSchemaIssue extends StandardSchemaV1.Issue { + readonly kind?: string; + readonly type?: string; + readonly expected?: string; + readonly received?: string; + readonly [k: string]: unknown; +} + +interface FriendlyIssue { + path: string; + message: string; +} + + +function formatPath(path: StandardSchemaIssue['path']): string { + if (!path || path.length === 0) return ''; + let out = ''; + for (const segment of path) { + // Standard-Schema allows two segment shapes — a raw `PropertyKey` + // (string / number / symbol) or a `PathSegment` object with `.key`. + // Valibot emits the object form; other validators may emit primitives. + // We coerce both. + const key = + typeof segment === 'object' && segment !== null && 'key' in segment ? segment.key : segment; + if (typeof key === 'number') out += `[${key}]`; + else if (key !== undefined && key !== null) out += out === '' ? String(key) : `.${String(key)}`; + } + return out; +} + +function summarizeIssue(issue: StandardSchemaIssue): FriendlyIssue { + const path = formatPath(issue.path); + + // Common valibot shape for a missing required object key: + // { kind: 'schema', type: 'object'|'strict_object', + // expected: '"fieldName"', received: 'undefined', message: 'Invalid key: …' } + const isMissingKey = + issue.kind === 'schema' && + (issue.type === 'object' || issue.type === 'strict_object' || issue.type === 'loose_object') && + typeof issue.expected === 'string' && + issue.expected.length >= 2 && + issue.expected.startsWith('"') && + issue.expected.endsWith('"') && + issue.received === 'undefined'; + + if (isMissingKey) { + const field = issue.expected!.slice(1, -1); + // valibot's path points to where the missing field WOULD live; the + // terminal segment is the field name itself, so the "at" suffix in the + // message should refer to the parent container (or be omitted at the + // top level). + const parentPathStr = formatPath((issue.path ?? []).slice(0, -1)); + const where = parentPathStr ? ` at \`${parentPathStr}\`` : ''; + return { path, message: `Missing required field \`${field}\`${where}.` }; + } + + return { path, message: issue.message }; +} + +/** + * Wrap a standard-schema validator so that validation failures contain + * trimmed, agent-readable issue objects instead of raw valibot metadata. + * + * Successful inputs pass through unchanged (we don't touch the parsed value). + * + * Standard Schema validators are POJOs by convention (data fields + a + * `~standard` slot, no prototype methods), so spread-and-replace is enough + * — no Proxy required. + */ +export function withFriendlyErrors( + schema: TSchema, +): TSchema { + const original = schema['~standard']; + return { + ...schema, + '~standard': { + ...original, + validate(input: unknown) { + const result = original.validate(input); + const trim = (r: Awaited>) => + // Cast: the spec issue type is the base shape; validators + // (notably valibot) augment with `kind`, `type`, `expected`, + // `received` — see {@link StandardSchemaIssue}. + r.issues + ? { issues: (r.issues as readonly StandardSchemaIssue[]).map(summarizeIssue) } + : r; + return (result instanceof Promise ? result.then(trim) : trim(result)) as ReturnType< + typeof original.validate + >; + }, + }, + }; +} diff --git a/packages/addon-mcp/src/utils/resolve-component-stories.test.ts b/packages/addon-mcp/src/utils/resolve-component-stories.test.ts new file mode 100644 index 00000000..a59bdd4b --- /dev/null +++ b/packages/addon-mcp/src/utils/resolve-component-stories.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import path from 'node:path'; +import type { StoryIndex } from 'storybook/internal/types'; + +// Static import would freeze the mock target before each test gets a chance +// to swap the underlying core-server module. We dynamically `import()` after +// `vi.doMock` instead — see each test below. + +// The resolver canonicalises every input path with `fs.realpathSync.native` and probes +// barrel candidates with `fs.existsSync`. These unit tests operate on synthetic paths that +// don't exist on disk, so we stub `node:fs`: realpath is identity (the path is its own +// canonical form) and existsSync is false (no barrel siblings — barrel expansion is covered +// by the live-endpoint eval probe noted below). +vi.mock('node:fs', () => { + const realpathSync: any = (p: string) => p; + realpathSync.native = (p: string) => p; + const fs = { realpathSync, existsSync: () => false }; + return { ...fs, default: fs }; +}); + +const FAKE_WORKING_DIR = '/repo'; +const BADGE_ABS = path.join(FAKE_WORKING_DIR, 'src/components/Badge/Badge.tsx'); +const BADGE_BARREL = path.join(FAKE_WORKING_DIR, 'src/components/Badge/index.ts'); + +function setupService(opts: { storiesByDep: Record> }) { + const stub = { + hasGraph: () => true, + lookup(dep: string): Map { + const m = opts.storiesByDep[dep]; + if (!m) return new Map(); + return new Map(Object.entries(m)); + }, + }; + vi.doMock('storybook/internal/core-server', () => ({ + experimental_getDependencyGraphService: () => stub, + })); +} + +function buildStoryIndex(byFile: Record): StoryIndex { + const entries: StoryIndex['entries'] = {}; + for (const [absStoryFile, ids] of Object.entries(byFile)) { + const relative = path.relative(FAKE_WORKING_DIR, absStoryFile); + for (const id of ids) { + entries[id] = { + type: 'story', + id, + name: id, + title: id, + importPath: relative, + tags: [], + } as StoryIndex['entries'][string]; + } + } + return { v: 5, entries }; +} + +function depsFor(byFile: Record = {}, workingDir = FAKE_WORKING_DIR) { + const index = buildStoryIndex(byFile); + return { getStoryIndex: async () => index, workingDir }; +} + +beforeEach(() => { + vi.resetModules(); +}); + +afterEach(() => { + vi.resetModules(); + vi.doUnmock('storybook/internal/core-server'); +}); + +describe('resolveComponentStories', () => { + it('strips trailing slashes so `Badge.tsx/` queries the file, not the parent-name barrel', async () => { + // Regression for the silent-corruption bug: when a caller pastes + // `Badge/Badge.tsx/`, the trailing slash flipped `basename === dirname` + // in the barrel-expansion heuristic and we returned stories that + // consumed the *barrel* (`Badge/index.ts`) instead of `Badge.tsx`. + setupService({ + storiesByDep: { + [BADGE_ABS]: { + '/repo/src/A.stories.tsx': 1, + '/repo/src/B.stories.tsx': 2, + }, + [BADGE_BARREL]: { + '/repo/src/C.stories.tsx': 1, // wholly unrelated barrel consumer + }, + }, + }); + const { resolveComponentStories } = await import('./resolve-component-stories.ts'); + const res = await resolveComponentStories( + { componentPaths: [`${BADGE_ABS}/`] }, + depsFor({ + '/repo/src/A.stories.tsx': ['a--default'], + '/repo/src/B.stories.tsx': ['b--default'], + '/repo/src/C.stories.tsx': ['c--default'], + }), + ); + expect(res.available).toBe(true); + expect(res.results?.[0]?.matches.map((m) => m.storyId).sort()).toEqual([ + 'a--default', + 'b--default', + ]); + // And critically does NOT include the barrel-only consumer: + expect(res.results?.[0]?.matches.map((m) => m.storyId)).not.toContain('c--default'); + }); + + it('resolves relative paths against the workingDir', async () => { + setupService({ + storiesByDep: { + [BADGE_ABS]: { '/repo/src/A.stories.tsx': 1 }, + }, + }); + const { resolveComponentStories } = await import('./resolve-component-stories.ts'); + const res = await resolveComponentStories( + { componentPaths: ['src/components/Badge/Badge.tsx'] }, + depsFor({ '/repo/src/A.stories.tsx': ['a--default'] }), + ); + expect(res.results?.[0]?.matches.map((m) => m.storyId)).toEqual(['a--default']); + }); + + it('normalizes redundant slashes (`/services//webapp/`)', async () => { + setupService({ + storiesByDep: { [BADGE_ABS]: { '/repo/src/A.stories.tsx': 1 } }, + }); + const { resolveComponentStories } = await import('./resolve-component-stories.ts'); + const res = await resolveComponentStories( + { componentPaths: ['/repo/src//components/Badge/Badge.tsx'] }, + depsFor({ '/repo/src/A.stories.tsx': ['a--default'] }), + ); + expect(res.results?.[0]?.matches.map((m) => m.storyId)).toEqual(['a--default']); + }); + + it('skips virtual: importPath entries when building the file→storyIds map', async () => { + setupService({ + storiesByDep: { [BADGE_ABS]: { '/repo/src/A.stories.tsx': 1 } }, + }); + const { resolveComponentStories } = await import('./resolve-component-stories.ts'); + const indexWithVirtual: StoryIndex = { + v: 5, + entries: { + 'a--default': { + type: 'story', + id: 'a--default', + name: 'Default', + title: 'A', + importPath: 'src/A.stories.tsx', + tags: [], + } as StoryIndex['entries'][string], + 'virtual--page': { + type: 'story', + id: 'virtual--page', + name: 'Virtual', + title: 'V', + importPath: 'virtual:storybook/auto-docs', + tags: [], + } as StoryIndex['entries'][string], + }, + }; + const res = await resolveComponentStories( + { componentPaths: [BADGE_ABS] }, + { getStoryIndex: async () => indexWithVirtual, workingDir: FAKE_WORKING_DIR }, + ); + expect(res.results?.[0]?.matches.map((m) => m.storyId)).toEqual(['a--default']); + }); + + // Note: `expandBarrelTargets` uses `fs.existsSync` to validate candidate barrel + // paths before adding them, so a pure-mock unit test would need fake files on + // disk to exercise the barrel branch. End-to-end barrel behaviour is covered + // by the live-endpoint probe `eval/get-stories-by-component/edge-case-probe.ts` + // (P9: `ShoppingCart/index.ts`). + + it('returns available:false when the service is not active', async () => { + vi.doMock('storybook/internal/core-server', () => ({ + experimental_getDependencyGraphService: () => undefined, + })); + const { resolveComponentStories } = await import('./resolve-component-stories.ts'); + const res = await resolveComponentStories({ componentPaths: [BADGE_ABS] }, depsFor()); + expect(res.available).toBe(false); + expect(res.reason).toMatch(/dependency graph is unavailable/); + }); + + it('returns available:false on older Storybook versions that lack the export', async () => { + // Backwards-compat path: the module loads but the named export is + // undefined (older Storybook). The dynamic-import probe must treat + // this identically to "service inactive". + vi.doMock('storybook/internal/core-server', () => ({})); + const { resolveComponentStories } = await import('./resolve-component-stories.ts'); + const res = await resolveComponentStories({ componentPaths: [BADGE_ABS] }, depsFor()); + expect(res.available).toBe(false); + expect(res.reason).toMatch(/dependency graph is unavailable/); + }); + + it('returns available:false when the graph build has not finished', async () => { + vi.doMock('storybook/internal/core-server', () => ({ + experimental_getDependencyGraphService: () => ({ + hasGraph: () => false, + lookup: () => new Map(), + }), + })); + const { resolveComponentStories } = await import('./resolve-component-stories.ts'); + const res = await resolveComponentStories({ componentPaths: [BADGE_ABS] }, depsFor()); + expect(res.available).toBe(false); + expect(res.reason).toMatch(/hasn't built/); + }); +}); diff --git a/packages/addon-mcp/src/utils/resolve-component-stories.ts b/packages/addon-mcp/src/utils/resolve-component-stories.ts new file mode 100644 index 00000000..73576ce7 --- /dev/null +++ b/packages/addon-mcp/src/utils/resolve-component-stories.ts @@ -0,0 +1,181 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { StoryIndex } from 'storybook/internal/types'; +import { getDependencyGraphService } from './change-detection.ts'; + +const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'] as const; +const INDEX_BASENAMES = SOURCE_EXTENSIONS.map((ext) => `index${ext}`); + +/** + * Expands `Foo/Foo.tsx` ↔ `Foo/index.tsx` so a query against either form hits stories that + * imported the other. Only expands when filename or dirname clearly names the directory's + * main export. + */ +function expandBarrelTargets(absoluteComponentPath: string): string[] { + const targets = new Set([absoluteComponentPath]); + const dir = path.dirname(absoluteComponentPath); + const ext = path.extname(absoluteComponentPath); + const base = path.basename(absoluteComponentPath, ext); + const dirName = path.basename(dir); + + if (base.toLowerCase() === 'index') { + for (const candidateExt of SOURCE_EXTENSIONS) { + const candidate = path.join(dir, `${dirName}${candidateExt}`); + if (fs.existsSync(candidate)) targets.add(candidate); + } + } else if (base.toLowerCase() === dirName.toLowerCase()) { + for (const indexBasename of INDEX_BASENAMES) { + const candidate = path.join(dir, indexBasename); + if (fs.existsSync(candidate)) targets.add(candidate); + } + } + + // Storybook's reverse index keys paths via `pathe.normalize` (forward slashes). + return [...targets].map((p) => path.normalize(p)); +} + +/** + * Canonicalises a path via `realpath`. Fixes silent misses on case-insensitive filesystems + * (macOS APFS) where `BUTTON/Button.tsx` and `Button/Button.tsx` resolve to the same file but + * only the canonical form is in the reverse-index keyset. + * + * Returns `undefined` if the path doesn't exist on disk — the caller surfaces that as a + * distinct "path not found" outcome rather than a generic "no stories". + */ +function canonicalise(absolutePath: string): string | undefined { + try { + return fs.realpathSync.native(absolutePath); + } catch { + return undefined; + } +} + +/** Map absolute story-file path → story IDs declared in that file. Skips virtual entries. */ +function buildStoryIdsByFile( + storyIndex: StoryIndex, + workingDir: string, +): Map> { + const storyIdsByFile = new Map>(); + for (const entry of Object.values(storyIndex.entries)) { + if (entry.type !== 'story' || entry.importPath.startsWith('virtual:')) continue; + const filePath = path.normalize(path.join(workingDir, entry.importPath)); + let ids = storyIdsByFile.get(filePath); + if (!ids) { + ids = new Set(); + storyIdsByFile.set(filePath, ids); + } + ids.add(entry.id); + } + return storyIdsByFile; +} + +export interface ComponentStoriesRequest { + componentPaths: string[]; +} + +export interface ComponentStoryDepth { + storyId: string; + depth: number; +} + +export interface ComponentStoriesResult { + /** Echoes the caller's input path, lightly normalized (trailing slashes etc. stripped). */ + componentPath: string; + matches: ComponentStoryDepth[]; + /** `true` when no file exists at the resolved absolute path — distinguishes "typo" from "no stories yet". */ + pathNotFound?: boolean; +} + +export interface ComponentStoriesResponse { + available: boolean; + reason?: string; + results?: ComponentStoriesResult[]; +} + +export interface ResolveComponentStoriesDeps { + /** Live story index; injectable so tests can pin a fixed value. */ + getStoryIndex: () => Promise; + /** Defaults to `process.cwd()`, matching the dev server. */ + workingDir?: string; +} + +/** + * Looks up stories that consume each component path via Storybook's reverse dependency index. + * Returns `{ available: false }` when the graph isn't reachable; otherwise per-path results. + */ +export async function resolveComponentStories( + request: ComponentStoriesRequest, + deps: ResolveComponentStoriesDeps, +): Promise { + const service = await getDependencyGraphService(); + if (!service) { + return { + available: false, + reason: + "Storybook's story dependency graph is unavailable. This Storybook version may not ship the API, or the dev server is not running.", + }; + } + + if (!service.hasGraph()) { + return { + available: false, + reason: + "Storybook's story dependency graph hasn't built. Confirm your builder supports change detection (e.g. Vite) and check Storybook startup logs.", + }; + } + + const workingDir = deps.workingDir ?? process.cwd(); + const storyIndex = await deps.getStoryIndex(); + const storyIdsByFile = buildStoryIdsByFile(storyIndex, workingDir); + + // Dedupe inputs so an agent that grep'd loosely doesn't get the same component echoed back twice. + const deduped = [...new Set(request.componentPaths)]; + + const results: ComponentStoriesResult[] = deduped.map((componentPath) => { + // Normalize first: a trailing slash on `/abs/Badge/Badge.tsx/` would otherwise flip the + // barrel-expansion heuristic and silently return barrel consumers instead of the file. + const absolute = path.resolve(workingDir, componentPath); + const canonical = canonicalise(absolute); + + // Echo a cleaned-up form of the input (no trailing slash / `..` segments) rather than the raw + // string. Keeps relative paths relative and absolute paths absolute. + const echo = path.normalize(componentPath); + + if (!canonical) { + return { componentPath: echo, matches: [], pathNotFound: true }; + } + + // Include both the input's normalized form and the canonical form. On case-insensitive + // filesystems they differ when the caller passed a wrong-case path; using both means we hit + // the graph regardless of which form the caller supplied. + const targets = new Set(expandBarrelTargets(absolute)); + if (canonical !== absolute) { + for (const t of expandBarrelTargets(canonical)) targets.add(t); + } + + // Merge hits across expanded targets; keep the minimum depth per storyId. + const byStoryId = new Map(); + for (const target of targets) { + const hits = service.lookup(target); + for (const [storyFile, depth] of hits.entries()) { + const storyIds = storyIdsByFile.get(storyFile); + if (!storyIds) continue; + for (const storyId of storyIds) { + const existing = byStoryId.get(storyId); + if (existing === undefined || depth < existing) byStoryId.set(storyId, depth); + } + } + } + + const matches: ComponentStoryDepth[] = [...byStoryId.entries()] + .map(([storyId, depth]) => ({ storyId, depth })) + .sort((a, b) => { + if (a.depth !== b.depth) return a.depth - b.depth; + return a.storyId.localeCompare(b.storyId); + }); + + return { componentPath: echo, matches }; + }); + + return { available: true, results }; +} From 0e69c1a659f0a94f6e5a4f4bacc53003678ea45e Mon Sep 17 00:00:00 2001 From: yannbf Date: Tue, 2 Jun 2026 08:45:07 +0200 Subject: [PATCH 2/7] refactor(addon-mcp): resolve the story index in-process via the storyIndexGenerator preset Replace the loopback `fetch(${origin}/index.json)` with an in-process lookup through `options.presets.apply('storyIndexGenerator').getIndex()`. The dev server registers and memoises a single StoryIndexGenerator, so this returns the live, HMR-fresh index with no network round-trip. - add utils/get-story-index.ts (getStoryIndex(options)) + tests - migrate get-changed-stories, preview-stories, display-review, run-story-tests and get-stories-by-component from fetchStoryIndex(origin) to getStoryIndex(options) - drop the now-redundant per-origin TTL cache in get-stories-by-component (the generator already memoises lastIndex) - remove utils/fetch-story-index.ts and update test mocks Co-Authored-By: Claude Opus 4.8 --- .../src/tools/display-review.test.ts | 10 +-- .../addon-mcp/src/tools/display-review.ts | 12 ++-- .../src/tools/get-changed-stories.test.ts | 10 +-- .../src/tools/get-changed-stories.ts | 10 +-- .../tools/get-stories-by-component.test.ts | 4 +- .../src/tools/get-stories-by-component.ts | 31 ++------- .../src/tools/preview-stories.test.ts | 24 +++---- .../addon-mcp/src/tools/preview-stories.ts | 9 ++- .../src/tools/run-story-tests.test.ts | 6 +- .../addon-mcp/src/tools/run-story-tests.ts | 4 +- .../src/utils/fetch-story-index.test.ts | 69 ------------------- .../addon-mcp/src/utils/fetch-story-index.ts | 27 -------- .../src/utils/get-story-index.test.ts | 53 ++++++++++++++ .../addon-mcp/src/utils/get-story-index.ts | 34 +++++++++ 14 files changed, 140 insertions(+), 163 deletions(-) delete mode 100644 packages/addon-mcp/src/utils/fetch-story-index.test.ts delete mode 100644 packages/addon-mcp/src/utils/fetch-story-index.ts create mode 100644 packages/addon-mcp/src/utils/get-story-index.test.ts create mode 100644 packages/addon-mcp/src/utils/get-story-index.ts diff --git a/packages/addon-mcp/src/tools/display-review.test.ts b/packages/addon-mcp/src/tools/display-review.test.ts index df2faedc..89bfff7b 100644 --- a/packages/addon-mcp/src/tools/display-review.test.ts +++ b/packages/addon-mcp/src/tools/display-review.test.ts @@ -5,7 +5,7 @@ import { addDisplayReviewTool, buildReviewUrl, type ReviewState } from './displa import { DISPLAY_REVIEW_TOOL_NAME } from './tool-names.ts'; import { PUSH_REVIEW_EVENT } from '../constants.ts'; import type { AddonContext } from '../types.ts'; -import * as fetchStoryIndex from '../utils/fetch-story-index.ts'; +import * as getStoryIndexModule from '../utils/get-story-index.ts'; import type { StoryIndex } from 'storybook/internal/types'; function makeIndex(ids: string[]): StoryIndex { @@ -119,7 +119,7 @@ describe('displayReviewTool', () => { emitted = []; // Default: every story ID used in the sampleReview resolves. Individual // tests override this to exercise the validation path. - vi.spyOn(fetchStoryIndex, 'fetchStoryIndex').mockResolvedValue( + vi.spyOn(getStoryIndexModule, 'getStoryIndex').mockResolvedValue( makeIndex(['button--primary', 'button--secondary', 'page--home']), ); const adapter = new ValibotJsonSchemaAdapter(); @@ -222,7 +222,7 @@ describe('displayReviewTool', () => { it('rejects the whole review when any story ID is not in the live index', async () => { // Two real IDs, two fabricated ones — the agent invented the // "--default" exports based on naming conventions. - vi.spyOn(fetchStoryIndex, 'fetchStoryIndex').mockResolvedValue( + vi.spyOn(getStoryIndexModule, 'getStoryIndex').mockResolvedValue( makeIndex(['button--primary', 'page--home']), ); const reviewWithFakes: ReviewState = { @@ -259,7 +259,7 @@ describe('displayReviewTool', () => { }); it('lists each unknown ID only once even if reused across collections', async () => { - vi.spyOn(fetchStoryIndex, 'fetchStoryIndex').mockResolvedValue(makeIndex([])); + vi.spyOn(getStoryIndexModule, 'getStoryIndex').mockResolvedValue(makeIndex([])); const review: ReviewState = { ...sampleReview, collections: [ @@ -286,7 +286,7 @@ describe('displayReviewTool', () => { }); it('skips the index fetch when there are no story IDs to validate', async () => { - const fetchSpy = vi.spyOn(fetchStoryIndex, 'fetchStoryIndex'); + const fetchSpy = vi.spyOn(getStoryIndexModule, 'getStoryIndex'); const callCountBefore = fetchSpy.mock.calls.length; const emptyReview: ReviewState = { ...sampleReview, diff --git a/packages/addon-mcp/src/tools/display-review.ts b/packages/addon-mcp/src/tools/display-review.ts index 5ba3901d..99e02881 100644 --- a/packages/addon-mcp/src/tools/display-review.ts +++ b/packages/addon-mcp/src/tools/display-review.ts @@ -2,7 +2,8 @@ import type { McpServer } from 'tmcp'; import * as v from 'valibot'; import type { AddonContext } from '../types.ts'; import { errorToMCPContent } from '../utils/errors.ts'; -import { fetchStoryIndex } from '../utils/fetch-story-index.ts'; +import { getStoryIndex } from '../utils/get-story-index.ts'; +import type { Options } from 'storybook/internal/types'; import { withFriendlyErrors } from '../utils/format-validation-issues.ts'; import { DEFAULT_MCP_ENDPOINT, PUSH_REVIEW_EVENT, REVIEW_PAGE_PATH } from '../constants.ts'; import { DISPLAY_REVIEW_TOOL_NAME } from './tool-names.ts'; @@ -103,7 +104,7 @@ export function buildReviewUrl(ctx: { */ async function collectUnknownStoryIds( collections: ReadonlyArray<{ readonly storyIds: ReadonlyArray }>, - origin: string, + options: Options, ): Promise { const requested = new Set(); const inOrder: string[] = []; @@ -117,7 +118,7 @@ async function collectUnknownStoryIds( } if (inOrder.length === 0) return []; - const index = await fetchStoryIndex(origin); + const index = await getStoryIndex(options); return inOrder.filter((id) => !index.entries[id]); } @@ -163,6 +164,9 @@ Always include the returned reviewUrl in your final user-facing response so the 'Cannot resolve the Storybook URL: missing trusted origin in addon context.', ); } + if (!customContext.options) { + throw new Error('Storybook options are required in addon context.'); + } // Validate every storyId against the live index before publishing. // Without this gate, fabricated IDs (e.g. derived from filenames or @@ -172,7 +176,7 @@ Always include the returned reviewUrl in your final user-facing response so the // real IDs via get-stories-by-component before retrying. const unknownIds = await collectUnknownStoryIds( input.collections, - customContext.origin, + customContext.options, ); if (unknownIds.length > 0) { throw new Error(formatUnknownStoryIdsError(unknownIds)); diff --git a/packages/addon-mcp/src/tools/get-changed-stories.test.ts b/packages/addon-mcp/src/tools/get-changed-stories.test.ts index 2cf7d780..fe21ec8e 100644 --- a/packages/addon-mcp/src/tools/get-changed-stories.test.ts +++ b/packages/addon-mcp/src/tools/get-changed-stories.test.ts @@ -3,7 +3,7 @@ import { McpServer } from 'tmcp'; import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot'; import { addGetChangedStoriesTool } from './get-changed-stories.ts'; import type { AddonContext } from '../types.ts'; -import * as fetchStoryIndex from '../utils/fetch-story-index.ts'; +import * as getStoryIndexModule from '../utils/get-story-index.ts'; import smallStoryIndexFixture from '../../fixtures/small-story-index.fixture.json' with { type: 'json' }; import { GET_CHANGED_STORIES_TOOL_NAME } from './tool-names.ts'; import type { StoryIndex } from 'storybook/internal/types'; @@ -82,7 +82,7 @@ describe('getChangedStoriesTool', () => { ); await addGetChangedStoriesTool(server); - vi.spyOn(fetchStoryIndex, 'fetchStoryIndex').mockResolvedValue( + vi.spyOn(getStoryIndexModule, 'getStoryIndex').mockResolvedValue( smallStoryIndexFixture as unknown as StoryIndex, ); }); @@ -138,7 +138,7 @@ describe('getChangedStoriesTool', () => { const response = await callTool(); const text = getResultText(response); - expect(fetchStoryIndex.fetchStoryIndex).toHaveBeenCalledWith('http://localhost:6006'); + expect(getStoryIndexModule.getStoryIndex).toHaveBeenCalledWith(testContext.options); expect(text).toMatchInlineSnapshot(` "Detected 3 changed stories (1 new, 1 modified, 1 related). @@ -317,12 +317,12 @@ describe('getChangedStoriesTool', () => { }), }); - const callCountBefore = vi.mocked(fetchStoryIndex.fetchStoryIndex).mock.calls.length; + const callCountBefore = vi.mocked(getStoryIndexModule.getStoryIndex).mock.calls.length; const response = await callTool(); const text = getResultText(response); expect(text).toBe('No new, modified, or related stories detected.'); - expect(vi.mocked(fetchStoryIndex.fetchStoryIndex).mock.calls.length).toBe(callCountBefore); + expect(vi.mocked(getStoryIndexModule.getStoryIndex).mock.calls.length).toBe(callCountBefore); }); it('appends an unreachable-files hint to the empty response when the working tree has uncommitted source files outside the story graph', async () => { diff --git a/packages/addon-mcp/src/tools/get-changed-stories.ts b/packages/addon-mcp/src/tools/get-changed-stories.ts index 689d9209..992df9c1 100644 --- a/packages/addon-mcp/src/tools/get-changed-stories.ts +++ b/packages/addon-mcp/src/tools/get-changed-stories.ts @@ -3,7 +3,7 @@ import { experimental_getStatusStore } from 'storybook/internal/core-server'; import { collectTelemetry } from '../telemetry.ts'; import type { AddonContext } from '../types.ts'; import { errorToMCPContent } from '../utils/errors.ts'; -import { fetchStoryIndex } from '../utils/fetch-story-index.ts'; +import { getStoryIndex } from '../utils/get-story-index.ts'; import { detectUnreachableChanges, formatPartialCoverageBanner, @@ -65,9 +65,9 @@ export async function addGetChangedStoriesTool(server: McpServer { try { - const { origin, disableTelemetry } = server.ctx.custom ?? {}; - if (!origin) { - throw new Error('Origin is required in addon context'); + const { options, disableTelemetry } = server.ctx.custom ?? {}; + if (!options) { + throw new Error('Storybook options are required in addon context'); } const statusStore = experimental_getStatusStore(CHANGE_DETECTION_TYPE); @@ -111,7 +111,7 @@ export async function addGetChangedStoriesTool(server: McpServer( ({ storyId, value }) => { const entry = index.entries[storyId]; diff --git a/packages/addon-mcp/src/tools/get-stories-by-component.test.ts b/packages/addon-mcp/src/tools/get-stories-by-component.test.ts index b5de9e1e..1bfc22f7 100644 --- a/packages/addon-mcp/src/tools/get-stories-by-component.test.ts +++ b/packages/addon-mcp/src/tools/get-stories-by-component.test.ts @@ -7,7 +7,7 @@ import { type ComponentStoryMatch, } from './get-stories-by-component.ts'; import type { AddonContext } from '../types.ts'; -import * as fetchStoryIndex from '../utils/fetch-story-index.ts'; +import * as getStoryIndexModule from '../utils/get-story-index.ts'; import * as componentStoriesModule from '../utils/resolve-component-stories.ts'; import type { ComponentStoriesResponse } from '../utils/resolve-component-stories.ts'; import smallStoryIndexFixture from '../../fixtures/small-story-index.fixture.json' with { type: 'json' }; @@ -56,7 +56,7 @@ describe('getStoriesByComponentTool', () => { ); await addGetStoriesByComponentTool(server); - vi.spyOn(fetchStoryIndex, 'fetchStoryIndex').mockResolvedValue( + vi.spyOn(getStoryIndexModule, 'getStoryIndex').mockResolvedValue( smallStoryIndexFixture as unknown as StoryIndex, ); }); diff --git a/packages/addon-mcp/src/tools/get-stories-by-component.ts b/packages/addon-mcp/src/tools/get-stories-by-component.ts index f3a021ec..af62c93c 100644 --- a/packages/addon-mcp/src/tools/get-stories-by-component.ts +++ b/packages/addon-mcp/src/tools/get-stories-by-component.ts @@ -1,9 +1,8 @@ import type { McpServer } from 'tmcp'; import * as v from 'valibot'; -import type { StoryIndex } from 'storybook/internal/types'; import { collectTelemetry } from '../telemetry.ts'; import { errorToMCPContent } from '../utils/errors.ts'; -import { fetchStoryIndex } from '../utils/fetch-story-index.ts'; +import { getStoryIndex } from '../utils/get-story-index.ts'; import { resolveComponentStories, type ComponentStoryDepth, @@ -15,26 +14,6 @@ import { PREVIEW_STORIES_TOOL_NAME, } from './tool-names.ts'; -/** - * Memoise the story index per origin for a brief window. Agents commonly fire several tool - * calls back-to-back within a single turn; without this each call re-hits `/index.json` over - * loopback HTTP. TTL is short so HMR-added stories appear quickly on the next turn. - */ -const STORY_INDEX_CACHE_TTL_MS = 2000; -const storyIndexCache = new Map }>(); - -function getStoryIndexCached(origin: string): Promise { - const now = Date.now(); - const cached = storyIndexCache.get(origin); - if (cached && now - cached.fetchedAt < STORY_INDEX_CACHE_TTL_MS) return cached.index; - const index = fetchStoryIndex(origin); - // Drop the cache entry if the fetch rejects, so the next caller retries instead of inheriting - // the rejection forever. - index.catch(() => storyIndexCache.delete(origin)); - storyIndexCache.set(origin, { fetchedAt: now, index }); - return index; -} - /** When omitted by the caller, applied internally to keep result sets actionable on real codebases. */ const DEFAULT_MAX_DISTANCE = 3; @@ -237,12 +216,12 @@ Results are sorted by \`distance\` (lower = stronger signal). Prefer the lowest- }, async (input) => { try { - const { origin, disableTelemetry } = server.ctx.custom ?? {}; - if (!origin) { - throw new Error('Origin is required in addon context'); + const { options, disableTelemetry } = server.ctx.custom ?? {}; + if (!options) { + throw new Error('Storybook options are required in addon context'); } - const index = await getStoryIndexCached(origin); + const index = await getStoryIndex(options); const lookup = await resolveComponentStories( { componentPaths: input.componentPaths }, { getStoryIndex: async () => index }, diff --git a/packages/addon-mcp/src/tools/preview-stories.test.ts b/packages/addon-mcp/src/tools/preview-stories.test.ts index 70bb8383..c304e05f 100644 --- a/packages/addon-mcp/src/tools/preview-stories.test.ts +++ b/packages/addon-mcp/src/tools/preview-stories.test.ts @@ -5,7 +5,7 @@ import { addPreviewStoriesTool } from './preview-stories.ts'; import type { AddonContext } from '../types.ts'; import smallStoryIndexFixture from '../../fixtures/small-story-index.fixture.json' with { type: 'json' }; import monorepoStoryIndexFixture from '../../fixtures/monorepo-story-index.fixture.json' with { type: 'json' }; -import * as fetchStoryIndex from '../utils/fetch-story-index.ts'; +import * as getStoryIndexModule from '../utils/get-story-index.ts'; import { PREVIEW_STORIES_TOOL_NAME } from './tool-names.ts'; vi.mock('storybook/internal/csf', () => ({ @@ -14,7 +14,7 @@ vi.mock('storybook/internal/csf', () => ({ describe('previewStoriesTool', () => { let server: McpServer; - let fetchStoryIndexSpy: any; + let getStoryIndexSpy: any; const testContext: AddonContext = { origin: 'http://localhost:6006', options: {} as any, @@ -56,9 +56,9 @@ describe('previewStoriesTool', () => { await addPreviewStoriesTool(server); - // Mock fetchStoryIndex to return the fixture - fetchStoryIndexSpy = vi.spyOn(fetchStoryIndex, 'fetchStoryIndex'); - fetchStoryIndexSpy.mockResolvedValue(smallStoryIndexFixture); + // Mock getStoryIndex to return the fixture + getStoryIndexSpy = vi.spyOn(getStoryIndexModule, 'getStoryIndex'); + getStoryIndexSpy.mockResolvedValue(smallStoryIndexFixture); }); it('should return story URL for a valid story', async () => { @@ -101,7 +101,7 @@ describe('previewStoriesTool', () => { ], }, }); - expect(fetchStoryIndexSpy).toHaveBeenCalledWith('http://localhost:6006'); + expect(getStoryIndexSpy).toHaveBeenCalledWith(testContext.options); }); it('should return story URL when input uses storyId', async () => { @@ -440,7 +440,7 @@ describe('previewStoriesTool', () => { }); it('should handle fetch errors gracefully', async () => { - fetchStoryIndexSpy.mockRejectedValue(new Error('Network timeout')); + getStoryIndexSpy.mockRejectedValue(new Error('Network timeout')); const request = { jsonrpc: '2.0' as const, @@ -710,7 +710,7 @@ describe('previewStoriesTool', () => { ], }, }); - expect(fetchStoryIndexSpy).toHaveBeenCalledWith('http://localhost:6006'); + expect(getStoryIndexSpy).toHaveBeenCalledWith(testContext.options); }); it('should return error message for story not found with Windows path', async () => { @@ -791,7 +791,7 @@ describe('previewStoriesTool', () => { it('should match stories when index.json importPath has no leading ./', async () => { // Simulate monorepo setup where Storybook runs from apps/storybook // but stories live in packages/ — index.json uses ../../packages/... - fetchStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture); + getStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture); const request = { jsonrpc: '2.0' as const, @@ -838,7 +838,7 @@ describe('previewStoriesTool', () => { it('should match stories when both index.json importPath and computed path have leading ./ and normalization still works', async () => { // Simulate running Storybook from root where index.json uses ./stories/... // The computed relative path also starts with ./stories/... and normalization preserves the match - fetchStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture); + getStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture); const request = { jsonrpc: '2.0' as const, @@ -884,7 +884,7 @@ describe('previewStoriesTool', () => { it('should match stories when index.json importPath has no ./ and computed path has ./', async () => { // index.json stores "stories/Utils/Helpers.stories.tsx" (no ./ prefix) // computed relative path would be "./stories/Utils/Helpers.stories.tsx" - fetchStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture); + getStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture); const request = { jsonrpc: '2.0' as const, @@ -929,7 +929,7 @@ describe('previewStoriesTool', () => { it('should match stories when path has dot-segments like ./stories/../stories/', async () => { // dot-segments should be canonicalized: ./stories/../stories/Button.stories.tsx -> ./stories/Button.stories.tsx - fetchStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture); + getStoryIndexSpy.mockResolvedValue(monorepoStoryIndexFixture); const request = { jsonrpc: '2.0' as const, diff --git a/packages/addon-mcp/src/tools/preview-stories.ts b/packages/addon-mcp/src/tools/preview-stories.ts index 90b0863f..62029503 100644 --- a/packages/addon-mcp/src/tools/preview-stories.ts +++ b/packages/addon-mcp/src/tools/preview-stories.ts @@ -3,7 +3,7 @@ import url from 'node:url'; import * as v from 'valibot'; import { collectTelemetry } from '../telemetry.ts'; import { buildArgsParam } from '../utils/build-args-param.ts'; -import { fetchStoryIndex } from '../utils/fetch-story-index.ts'; +import { getStoryIndex } from '../utils/get-story-index.ts'; import { findStoryIds } from '../utils/find-story-ids.ts'; import { errorToMCPContent } from '../utils/errors.ts'; import type { AddonContext } from '../types.ts'; @@ -106,13 +106,16 @@ Always include each returned preview URL in your final user-facing response so u }, async (input) => { try { - const { origin, disableTelemetry } = server.ctx.custom ?? {}; + const { origin, options, disableTelemetry } = server.ctx.custom ?? {}; if (!origin) { throw new Error('Origin is required in addon context'); } + if (!options) { + throw new Error('Storybook options are required in addon context'); + } - const index = await fetchStoryIndex(origin); + const index = await getStoryIndex(options); const resolvedStories = findStoryIds(index, input.stories); const structuredResult: PreviewStoriesOutput['stories'] = []; diff --git a/packages/addon-mcp/src/tools/run-story-tests.test.ts b/packages/addon-mcp/src/tools/run-story-tests.test.ts index 062dd389..735b9453 100644 --- a/packages/addon-mcp/src/tools/run-story-tests.test.ts +++ b/packages/addon-mcp/src/tools/run-story-tests.test.ts @@ -4,7 +4,7 @@ import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot'; import { addRunStoryTestsTool, getAddonVitestConstants } from './run-story-tests.ts'; import type { AddonContext } from '../types.ts'; import smallStoryIndexFixture from '../../fixtures/small-story-index.fixture.json' with { type: 'json' }; -import * as fetchStoryIndex from '../utils/fetch-story-index.ts'; +import * as getStoryIndexModule from '../utils/get-story-index.ts'; import type { TriggerTestRunResponsePayload } from '@storybook/addon-vitest/constants'; import { RUN_STORY_TESTS_TOOL_NAME } from './tool-names.ts'; @@ -156,8 +156,8 @@ describe('runStoryTestsTool', () => { await addRunStoryTestsTool(server, { a11yEnabled: true }); - // Mock fetchStoryIndex to return the fixture - vi.spyOn(fetchStoryIndex, 'fetchStoryIndex').mockResolvedValue(smallStoryIndexFixture as any); + // Mock getStoryIndex to return the fixture + vi.spyOn(getStoryIndexModule, 'getStoryIndex').mockResolvedValue(smallStoryIndexFixture as any); }); it('should include visual a11y handling guidance in tool description', async () => { diff --git a/packages/addon-mcp/src/tools/run-story-tests.ts b/packages/addon-mcp/src/tools/run-story-tests.ts index 588035fb..efe0ea06 100644 --- a/packages/addon-mcp/src/tools/run-story-tests.ts +++ b/packages/addon-mcp/src/tools/run-story-tests.ts @@ -1,7 +1,7 @@ import type { McpServer } from 'tmcp'; import { logger } from 'storybook/internal/node-logger'; import * as v from 'valibot'; -import { fetchStoryIndex } from '../utils/fetch-story-index.ts'; +import { getStoryIndex } from '../utils/get-story-index.ts'; import { findStoryIds, type FoundStory, type NotFoundStory } from '../utils/find-story-ids.ts'; import { errorToMCPContent } from '../utils/errors.ts'; import { collectTelemetry } from '../telemetry.ts'; @@ -146,7 +146,7 @@ For visual/design accessibility violations (for example color contrast), ask the let inputStoryCount = 0; if (input.stories) { - const index = await fetchStoryIndex(origin); + const index = await getStoryIndex(options); const resolvedStories = findStoryIds(index, input.stories); storyIds = resolvedStories diff --git a/packages/addon-mcp/src/utils/fetch-story-index.test.ts b/packages/addon-mcp/src/utils/fetch-story-index.test.ts deleted file mode 100644 index d2abcf3e..00000000 --- a/packages/addon-mcp/src/utils/fetch-story-index.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { fetchStoryIndex } from './fetch-story-index.ts'; -import smallStoryIndexFixture from '../../fixtures/small-story-index.fixture.json' with { type: 'json' }; - -describe('fetchStoryIndex', () => { - const originalFetch = global.fetch; - - beforeEach(() => { - // Mock fetch globally - global.fetch = vi.fn(); - }); - - afterEach(() => { - global.fetch = originalFetch; - }); - - it('should fetch and return story index successfully', async () => { - const mockFetch = global.fetch as any; - mockFetch.mockResolvedValue({ - ok: true, - json: async () => smallStoryIndexFixture, - }); - - const origin = 'http://localhost:6006'; - const result = await fetchStoryIndex(origin); - - expect(mockFetch).toHaveBeenCalledWith('http://localhost:6006/index.json'); - expect(result).toEqual(smallStoryIndexFixture); - expect(Object.keys(result.entries)).toHaveLength(3); - }); - - it('should throw error on 404 response', async () => { - const mockFetch = global.fetch as any; - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - statusText: 'Not Found', - }); - - const origin = 'http://localhost:6006'; - - await expect(fetchStoryIndex(origin)).rejects.toThrow( - 'Failed to fetch story index: 404 Not Found', - ); - }); - - it('should throw error on network failure', async () => { - const mockFetch = global.fetch as any; - mockFetch.mockRejectedValue(new Error('Network error')); - - const origin = 'http://localhost:6006'; - - await expect(fetchStoryIndex(origin)).rejects.toThrow('Network error'); - }); - - it('should handle invalid JSON response', async () => { - const mockFetch = global.fetch as any; - mockFetch.mockResolvedValue({ - ok: true, - json: async () => { - throw new Error('Invalid JSON'); - }, - }); - - const origin = 'http://localhost:6006'; - - await expect(fetchStoryIndex(origin)).rejects.toThrow('Invalid JSON'); - }); -}); diff --git a/packages/addon-mcp/src/utils/fetch-story-index.ts b/packages/addon-mcp/src/utils/fetch-story-index.ts deleted file mode 100644 index 50a85bcc..00000000 --- a/packages/addon-mcp/src/utils/fetch-story-index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { StoryIndex } from 'storybook/internal/types'; -import { logger } from 'storybook/internal/node-logger'; - -/** - * Fetches the Storybook story index from the running Storybook instance. - * - * @param origin - The origin URL of the Storybook instance (e.g., http://localhost:6006) - * @returns A promise that resolves to the StoryIndex - * @throws If the fetch fails or returns invalid data - */ -export async function fetchStoryIndex(origin: string): Promise { - const indexUrl = `${origin}/index.json`; - - logger.debug(`Fetching story index from: ${indexUrl}`); - - const response = await fetch(indexUrl); - - if (!response.ok) { - throw new Error(`Failed to fetch story index: ${response.status} ${response.statusText}`); - } - - const index = (await response.json()) as StoryIndex; - - logger.debug(`Story index entries found: ${Object.keys(index.entries).length}`); - - return index; -} diff --git a/packages/addon-mcp/src/utils/get-story-index.test.ts b/packages/addon-mcp/src/utils/get-story-index.test.ts new file mode 100644 index 00000000..c5aa1d71 --- /dev/null +++ b/packages/addon-mcp/src/utils/get-story-index.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Options, StoryIndex } from 'storybook/internal/types'; +import { getStoryIndex } from './get-story-index.ts'; + +function makeOptions(apply: Options['presets']['apply']): Options { + return { presets: { apply } } as unknown as Options; +} + +const index: StoryIndex = { + v: 5, + entries: { + 'button--primary': { + type: 'story', + id: 'button--primary', + name: 'Primary', + title: 'Button', + importPath: './Button.stories.tsx', + tags: [], + }, + }, +} as unknown as StoryIndex; + +describe('getStoryIndex', () => { + it('resolves the index from the storyIndexGenerator preset', async () => { + const getIndex = vi.fn().mockResolvedValue(index); + const apply = vi.fn().mockResolvedValue({ getIndex }); + const options = makeOptions(apply as unknown as Options['presets']['apply']); + + const result = await getStoryIndex(options); + + expect(apply).toHaveBeenCalledWith('storyIndexGenerator'); + expect(getIndex).toHaveBeenCalledOnce(); + expect(result).toBe(index); + }); + + it('throws a clear error when the generator preset is unavailable', async () => { + const apply = vi.fn().mockResolvedValue(undefined); + const options = makeOptions(apply as unknown as Options['presets']['apply']); + + await expect(getStoryIndex(options)).rejects.toThrow( + /story index generator is unavailable/, + ); + }); + + it('propagates errors thrown by the generator', async () => { + const apply = vi + .fn() + .mockResolvedValue({ getIndex: vi.fn().mockRejectedValue(new Error('boom')) }); + const options = makeOptions(apply as unknown as Options['presets']['apply']); + + await expect(getStoryIndex(options)).rejects.toThrow('boom'); + }); +}); diff --git a/packages/addon-mcp/src/utils/get-story-index.ts b/packages/addon-mcp/src/utils/get-story-index.ts new file mode 100644 index 00000000..544b12bc --- /dev/null +++ b/packages/addon-mcp/src/utils/get-story-index.ts @@ -0,0 +1,34 @@ +import type { Options, StoryIndex } from 'storybook/internal/types'; +import type { StoryIndexGenerator } from 'storybook/internal/core-server'; +import { logger } from 'storybook/internal/node-logger'; + +/** + * Resolves the Storybook story index in-process via the `storyIndexGenerator` preset, rather than + * fetching `/index.json` over loopback HTTP. + * + * The dev-server registers and memoises a single `StoryIndexGenerator` (see Storybook's + * `common-preset`); applying the preset here returns that same live instance, so `getIndex()` + * hands back the up-to-date, internally-cached index without a network round-trip. This also keeps + * the index in lock-step with HMR — the generator's watcher refreshes `lastIndex` as files change. + * + * @param options - The Storybook options object carrying the resolved presets. + * @returns A promise that resolves to the StoryIndex. + * @throws If the generator preset is unavailable (e.g. not running inside a dev server). + */ +export async function getStoryIndex(options: Options): Promise { + const generator = await options.presets.apply( + 'storyIndexGenerator', + ); + + if (!generator) { + throw new Error( + 'Storybook story index generator is unavailable. These MCP tools require a running Storybook dev server with a builder that exposes the story index.', + ); + } + + const index = await generator.getIndex(); + + logger.debug(`Story index entries found: ${Object.keys(index.entries).length}`); + + return index; +} From a01160d038b0ddcae7c2ab36f873919695ff2f8b Mon Sep 17 00:00:00 2001 From: yannbf Date: Tue, 2 Jun 2026 13:52:45 +0200 Subject: [PATCH 3/7] Address review comments --- .changeset/curly-pugs-discover.md | 5 +++++ .../addon-mcp/src/instructions/dev-instructions.md | 4 ++-- .../src/tools/get-stories-by-component.test.ts | 4 ---- .../addon-mcp/src/tools/get-stories-by-component.ts | 5 +++-- .../addon-mcp/src/utils/detect-unreachable-changes.ts | 5 ++++- .../addon-mcp/src/utils/resolve-component-stories.ts | 10 +++++++--- 6 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 .changeset/curly-pugs-discover.md diff --git a/.changeset/curly-pugs-discover.md b/.changeset/curly-pugs-discover.md new file mode 100644 index 00000000..2fbca59b --- /dev/null +++ b/.changeset/curly-pugs-discover.md @@ -0,0 +1,5 @@ +--- +"@storybook/addon-mcp": minor +--- + +Add the `get-stories-by-component` tool. Maps component source files to the stories that render them via Storybook's live reverse dependency graph, returning grounded story IDs ranked by import distance. Also hardens change detection: `get-changed-stories` now surfaces working-tree files that are unreachable from any story, and story-index resolution and reverse-graph lookups are normalized for cross-platform (Windows) path handling. diff --git a/packages/addon-mcp/src/instructions/dev-instructions.md b/packages/addon-mcp/src/instructions/dev-instructions.md index 6d516f23..db61e848 100644 --- a/packages/addon-mcp/src/instructions/dev-instructions.md +++ b/packages/addon-mcp/src/instructions/dev-instructions.md @@ -11,8 +11,8 @@ Whenever you need story IDs — to preview them, to feed `display-review`, to answer the user, for any reason at all — your job is the same regardless of how the request reached you. The input can take any shape: a feature/domain/topic the user named, a file the user mentioned, a file you just edited, a query like "all consumers of X", an autonomous review after a UI change, or anything else. The chain doesn't change with the prompt shape: 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is *shared* infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass *their* paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made *multiple* edits in the same session, `get-changed-stories` returns the *cumulative* diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call `get-stories-by-component` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. -2. **Call `get-stories-by-component`** with those absolute paths. It returns grounded `storyId` values from the live Storybook index, ranked by `distance` (0 = colocated, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like `list-all-documentation`) are safe to use. -3. **Bucket by `distance`.** Default to `maxDistance: 2` and tighten only when you have a reason to. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the `distance 1` bucket is often empty — capping at `1` would hide the entire cascade. Page-level components can also see surprising `distance 3+` matches when Storybook decorators pull in wide swaths of the app; `maxDistance: 2` defuses that without losing real consumers. For `display-review`, the buckets map directly onto the visual cascade: `0` = the component itself, `1` = direct importers, `2+` (capped via `maxDistance`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. +2. **Call `get-stories-by-component`** with those absolute paths. It returns grounded `storyId` values from the live Storybook index, ranked by `distance` (0 = the path you passed is itself a story file, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like `list-all-documentation`) are safe to use. +3. **Bucket by `distance`.** The tool caps results at `maxDistance: 3` by default; lower it to tighten precision when you have a reason to, or raise it to widen recall. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the `distance 1` bucket is often empty — capping at `1` would hide the entire cascade. Page-level components can also see surprising `distance 3+` matches when Storybook decorators pull in wide swaths of the app; the default cap defuses most of that without losing real consumers. For `display-review`, the buckets map directly onto the visual cascade: `0` = the component itself, `1` = direct importers, `2+` (capped via `maxDistance`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. 4. **Pass the resulting `storyId`s into `preview-stories`** for preview URLs, or into `display-review` for a curated review page (per the visibility guidance above — skip review for changes with no expected visual impact). Never invent story IDs from file names, feature names, or memory — Storybook IDs come from the live index and are the only ones that resolve. If `get-stories-by-component` returns no matches for a component, that component genuinely has no stories yet; tell the user rather than fabricating IDs. `display-review` validates every ID against the live index server-side and will hard-fail the whole review if any are unknown — there is no "soft" guess that slips through. diff --git a/packages/addon-mcp/src/tools/get-stories-by-component.test.ts b/packages/addon-mcp/src/tools/get-stories-by-component.test.ts index 1bfc22f7..81652111 100644 --- a/packages/addon-mcp/src/tools/get-stories-by-component.test.ts +++ b/packages/addon-mcp/src/tools/get-stories-by-component.test.ts @@ -91,7 +91,6 @@ describe('getStoriesByComponentTool', () => { it('returns stories with their reverse-graph depth', async () => { mockLookup({ available: true, - workingDir: cwd, results: [ { componentPath: `${cwd}/src/Button.tsx`, @@ -141,7 +140,6 @@ describe('getStoriesByComponentTool', () => { it('reports components with no stories', async () => { mockLookup({ available: true, - workingDir: cwd, results: [{ componentPath: `${cwd}/src/Missing.tsx`, matches: [] }], }); @@ -154,7 +152,6 @@ describe('getStoriesByComponentTool', () => { it('handles multiple components in a single call', async () => { mockLookup({ available: true, - workingDir: cwd, results: [ { componentPath: `${cwd}/src/Input.tsx`, @@ -192,7 +189,6 @@ describe('getStoriesByComponentTool', () => { it('applies maxDistance and records clipped tail', async () => { mockLookup({ available: true, - workingDir: cwd, results: [ { componentPath: `${cwd}/src/Button.tsx`, diff --git a/packages/addon-mcp/src/tools/get-stories-by-component.ts b/packages/addon-mcp/src/tools/get-stories-by-component.ts index af62c93c..b9925f34 100644 --- a/packages/addon-mcp/src/tools/get-stories-by-component.ts +++ b/packages/addon-mcp/src/tools/get-stories-by-component.ts @@ -24,6 +24,7 @@ const GetStoriesByComponentInput = v.object({ v.description( `Absolute paths to component source files (e.g. "/repo/src/Button.tsx"). Pass the components you actually want stories for — typically files you just read, edited, or that the user mentioned. +Relative paths are also accepted and resolved against the Storybook working directory, but absolute paths are preferred for unambiguous results. Story files (\`*.stories.*\`) are accepted too: they appear at distance 0 as self-matches, plus any reverse-graph hits (other stories that import them).`, ), ), @@ -46,7 +47,7 @@ const StoryMatch = v.object({ distance: v.pipe( v.number(), v.description( - 'Import-graph depth from the story file to the component (lower = stronger). 1: story file directly imports the component. 2+: reached through N hops.', + 'Import-graph depth from the story file to the component (lower = stronger). 0: the path you passed is itself a story file (self-match). 1: story file directly imports the component. 2+: reached through N hops.', ), ), }); @@ -207,7 +208,7 @@ export async function addGetStoriesByComponentTool(server: McpServer const unreachable: string[] = []; for (const rel of relFiles) { - const abs = path.resolve(workingDir, rel); + // The reverse graph keys are forward-slash normalized; `path.resolve` emits backslashes on + // Windows, which would miss every key and wrongly flag reachable files as unreachable. + const abs = slash(path.resolve(workingDir, rel)); const hits = service.lookup(abs); if (hits.size === 0) unreachable.push(rel); if (unreachable.length >= maxFiles) break; diff --git a/packages/addon-mcp/src/utils/resolve-component-stories.ts b/packages/addon-mcp/src/utils/resolve-component-stories.ts index 73576ce7..861f2b2f 100644 --- a/packages/addon-mcp/src/utils/resolve-component-stories.ts +++ b/packages/addon-mcp/src/utils/resolve-component-stories.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { StoryIndex } from 'storybook/internal/types'; import { getDependencyGraphService } from './change-detection.ts'; +import { slash } from './slash.ts'; const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'] as const; const INDEX_BASENAMES = SOURCE_EXTENSIONS.map((ext) => `index${ext}`); @@ -30,8 +31,9 @@ function expandBarrelTargets(absoluteComponentPath: string): string[] { } } - // Storybook's reverse index keys paths via `pathe.normalize` (forward slashes). - return [...targets].map((p) => path.normalize(p)); + // Storybook's reverse index keys paths via `pathe.normalize` (forward slashes), so emit + // forward slashes even on Windows where `path.normalize` would produce backslashes. + return [...targets].map((p) => slash(path.normalize(p))); } /** @@ -58,7 +60,9 @@ function buildStoryIdsByFile( const storyIdsByFile = new Map>(); for (const entry of Object.values(storyIndex.entries)) { if (entry.type !== 'story' || entry.importPath.startsWith('virtual:')) continue; - const filePath = path.normalize(path.join(workingDir, entry.importPath)); + // Keys must match `service.lookup`'s forward-slash-normalized keys; `path.join` emits + // backslashes on Windows, so normalize the separators here. + const filePath = slash(path.join(workingDir, entry.importPath)); let ids = storyIdsByFile.get(filePath); if (!ids) { ids = new Set(); From 3615cf287a4f6d3c141f86ab603fc19d9653a2ce Mon Sep 17 00:00:00 2001 From: yannbf Date: Tue, 2 Jun 2026 13:59:28 +0200 Subject: [PATCH 4/7] fix --- .../tests/mcp-endpoint.e2e.test.ts | 14 ++++++++------ .../src/tools/get-stories-by-component.test.ts | 13 +++++++------ .../src/tools/get-stories-by-component.ts | 4 +--- .../src/utils/resolve-component-stories.ts | 5 +---- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts b/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts index d3cf3dbb..7c6fd3ce 100644 --- a/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts +++ b/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts @@ -387,7 +387,7 @@ describe('MCP Endpoint E2E Tests', () => { Returns sorted results from the Storybook index — if a component has no matches here, it likely has no stories yet (say so, don't fabricate). - Backed by Storybook's live change-detection reverse dependency graph: distance is the import-graph hop count from the story file to the component (1 = directly imported, 2+ = transitively). Requires \`features.changeDetection\` to be enabled in the Storybook config; if not, the tool returns a typed error. + Backed by Storybook's live reverse dependency graph: distance is the import-graph hop count from the story file to the component (0 = the path you passed is itself a story file, 1 = directly imported, 2+ = transitively). Available when the Storybook dev server is running with a builder that supports change detection (e.g. Vite); otherwise the tool returns a typed error. Results are sorted by \`distance\` (lower = stronger signal). Prefer the lowest-distance results first; widen only when needed. For shared components like Button or Icon, expect many indirect (\`distance\` ≥ 2) matches — pass \`maxDistance\` to cap noise.", "inputSchema": { @@ -396,7 +396,8 @@ describe('MCP Endpoint E2E Tests', () => { "componentPaths": { "description": "Absolute paths to component source files (e.g. "/repo/src/Button.tsx"). Pass the components you actually want stories for — typically files you just read, edited, or that the user mentioned. - Do not pass story files (\`*.stories.*\`); pass the component the story renders.", + Relative paths are also accepted and resolved against the Storybook working directory, but absolute paths are preferred for unambiguous results. + Story files (\`*.stories.*\`) are accepted too: they appear at distance 0 as self-matches, plus any reverse-graph hits (other stories that import them).", "items": { "type": "string", }, @@ -404,11 +405,12 @@ describe('MCP Endpoint E2E Tests', () => { "type": "array", }, "maxDistance": { - "description": "Optional ceiling on the import depth to include in results. + "description": "Ceiling on the import depth to include in results. Must be a positive integer. - 1: only stories that directly import the component. - 2+: also include stories that reach the component through N hops. - Omit to include everything. Lower values trade recall for precision; useful when one shared component (Button, Icon, …) would otherwise sweep in dozens of consumer stories.", - "type": "number", + Defaults to 3; raise it to widen recall, lower it to tighten precision. Shared components (Button, Icon, …) accumulate noisy indirect matches at distance ≥ 3, so the default cap protects against runaway results.", + "minimum": 1, + "type": "integer", }, }, "required": [ @@ -449,7 +451,7 @@ describe('MCP Endpoint E2E Tests', () => { "items": { "properties": { "distance": { - "description": "Import-graph depth from the story file to the component (lower = stronger). 1: story file directly imports the component. 2+: reached through N hops.", + "description": "Import-graph depth from the story file to the component (lower = stronger). 0: the path you passed is itself a story file (self-match). 1: story file directly imports the component. 2+: reached through N hops.", "type": "number", }, "importPath": { diff --git a/packages/addon-mcp/src/tools/get-stories-by-component.test.ts b/packages/addon-mcp/src/tools/get-stories-by-component.test.ts index 81652111..e0e18076 100644 --- a/packages/addon-mcp/src/tools/get-stories-by-component.test.ts +++ b/packages/addon-mcp/src/tools/get-stories-by-component.test.ts @@ -240,7 +240,9 @@ describe('serializeComponentSection', () => { }); it('singularizes the summary when there is exactly one match in one component', () => { - const text = serializeComponentSection('/repo/src/Button.tsx', [m('button--primary', 'Button', 0)]); + const text = serializeComponentSection('/repo/src/Button.tsx', [ + m('button--primary', 'Button', 0), + ]); expect(text).toContain('→ 1 story across 1 component, distances 0..0 (d0=1)'); }); @@ -264,11 +266,10 @@ describe('serializeComponentSection', () => { }); it('emits singular phrasing when exactly one match was clipped', () => { - const text = serializeComponentSection( - '/repo/src/Lib.ts', - [m('a--default', 'A', 0)], - { maxDistance: 1, clipped: { count: 1, distances: [2] } }, - ); + const text = serializeComponentSection('/repo/src/Lib.ts', [m('a--default', 'A', 0)], { + maxDistance: 1, + clipped: { count: 1, distances: [2] }, + }); expect(text).toContain('+1 more story at distance 2 hidden by `maxDistance: 1`'); }); diff --git a/packages/addon-mcp/src/tools/get-stories-by-component.ts b/packages/addon-mcp/src/tools/get-stories-by-component.ts index b9925f34..17d8754c 100644 --- a/packages/addon-mcp/src/tools/get-stories-by-component.ts +++ b/packages/addon-mcp/src/tools/get-stories-by-component.ts @@ -278,9 +278,7 @@ Results are sorted by \`distance\` (lower = stronger signal). Prefer the lowest- ); const text = - textSections.length > 0 - ? textSections.join('\n\n') - : 'No component paths provided.'; + textSections.length > 0 ? textSections.join('\n\n') : 'No component paths provided.'; if (!disableTelemetry) { await collectTelemetry({ diff --git a/packages/addon-mcp/src/utils/resolve-component-stories.ts b/packages/addon-mcp/src/utils/resolve-component-stories.ts index 861f2b2f..a50bfb3c 100644 --- a/packages/addon-mcp/src/utils/resolve-component-stories.ts +++ b/packages/addon-mcp/src/utils/resolve-component-stories.ts @@ -53,10 +53,7 @@ function canonicalise(absolutePath: string): string | undefined { } /** Map absolute story-file path → story IDs declared in that file. Skips virtual entries. */ -function buildStoryIdsByFile( - storyIndex: StoryIndex, - workingDir: string, -): Map> { +function buildStoryIdsByFile(storyIndex: StoryIndex, workingDir: string): Map> { const storyIdsByFile = new Map>(); for (const entry of Object.values(storyIndex.entries)) { if (entry.type !== 'story' || entry.importPath.startsWith('virtual:')) continue; From 2f9ff4101c41999167935c4656498b042ba866d1 Mon Sep 17 00:00:00 2001 From: yannbf Date: Tue, 2 Jun 2026 14:09:10 +0200 Subject: [PATCH 5/7] address comments --- .../tests/mcp-endpoint.e2e.test.ts | 12 ++++-------- .../addon-mcp/src/tools/get-stories-by-component.ts | 12 ++++-------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts b/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts index 7c6fd3ce..6d68777e 100644 --- a/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts +++ b/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts @@ -379,17 +379,13 @@ describe('MCP Endpoint E2E Tests', () => { "title": "Get changed stories metadata", }, { - "description": "Map component source files to the stories that render them, so you can hand real story IDs to preview-stories instead of guessing. + "description": "Map component source files to the stories that render them, returning grounded \`storyId\` values from the live Storybook index — hand these to preview-stories instead of guessing. - **When to use this vs \`get-changed-stories\`:** if the user just edited code, call \`get-changed-stories\` first — it reads Storybook's live git-diff signal for free. Only call this tool when you need to map specific file paths to stories: the user described a feature/area by name, \`get-changed-stories\` returned nothing (the change is outside the story graph and you need to find runtime consumers yourself), or it returned too much and you need to narrow. + Reach for this to map specific file paths to stories: when the user names a feature/area, or when \`get-changed-stories\` (try it first for "I just edited X") returned nothing or too much. The full file-paths → story-IDs workflow lives in the server instructions. - **Use this whenever the user describes a part of the UI by feature, area, or topic** ("review the credit-card components", "preview every checkout story", "show me what cart looks like", "stories related to authentication") — first locate the relevant component files in the repo (grep/Glob), then pass their absolute paths here. The tool returns grounded \`storyId\` values from the live Storybook index; never invent IDs from file names, feature names, or memory. + Never invent IDs from file names, feature names, or memory; if a component has no matches here, it has no stories yet (say so, don't fabricate). - Returns sorted results from the Storybook index — if a component has no matches here, it likely has no stories yet (say so, don't fabricate). - - Backed by Storybook's live reverse dependency graph: distance is the import-graph hop count from the story file to the component (0 = the path you passed is itself a story file, 1 = directly imported, 2+ = transitively). Available when the Storybook dev server is running with a builder that supports change detection (e.g. Vite); otherwise the tool returns a typed error. - - Results are sorted by \`distance\` (lower = stronger signal). Prefer the lowest-distance results first; widen only when needed. For shared components like Button or Icon, expect many indirect (\`distance\` ≥ 2) matches — pass \`maxDistance\` to cap noise.", + Backed by Storybook's live reverse dependency graph, available only when the dev server runs a builder that supports change detection (e.g. Vite) — otherwise returns a typed error. Results are sorted by \`distance\` (lower = stronger); for shared components like Button or Icon, expect many indirect matches and use \`maxDistance\` to cap noise.", "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/packages/addon-mcp/src/tools/get-stories-by-component.ts b/packages/addon-mcp/src/tools/get-stories-by-component.ts index 17d8754c..ed707e22 100644 --- a/packages/addon-mcp/src/tools/get-stories-by-component.ts +++ b/packages/addon-mcp/src/tools/get-stories-by-component.ts @@ -200,17 +200,13 @@ export async function addGetStoriesByComponentTool(server: McpServer server.ctx.custom?.toolsets?.dev ?? true, From 21fac87db731bb7743f1ccdae6700c670b6546fb Mon Sep 17 00:00:00 2001 From: yannbf Date: Tue, 2 Jun 2026 14:16:25 +0200 Subject: [PATCH 6/7] fix formatting etc --- .../build-server-instructions.test.ts | 24 +++++++++---------- .../src/instructions/dev-instructions.md | 2 +- packages/addon-mcp/src/mcp-handler.ts | 3 +-- .../addon-mcp/src/tools/display-review.ts | 5 +--- .../src/tools/get-changed-stories.test.ts | 4 +--- .../utils/detect-unreachable-changes.test.ts | 4 +--- .../src/utils/format-validation-issues.ts | 5 +--- .../src/utils/get-story-index.test.ts | 4 +--- 8 files changed, 19 insertions(+), 32 deletions(-) diff --git a/packages/addon-mcp/src/instructions/build-server-instructions.test.ts b/packages/addon-mcp/src/instructions/build-server-instructions.test.ts index e9e2a373..df92dcdc 100644 --- a/packages/addon-mcp/src/instructions/build-server-instructions.test.ts +++ b/packages/addon-mcp/src/instructions/build-server-instructions.test.ts @@ -27,9 +27,9 @@ describe('buildServerInstructions', () => { Whenever you need story IDs — to preview them, to feed \`display-review\`, to answer the user, for any reason at all — your job is the same regardless of how the request reached you. The input can take any shape: a feature/domain/topic the user named, a file the user mentioned, a file you just edited, a query like "all consumers of X", an autonomous review after a UI change, or anything else. The chain doesn't change with the prompt shape: - 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is *shared* infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass *their* paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made *multiple* edits in the same session, \`get-changed-stories\` returns the *cumulative* diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call \`get-stories-by-component\` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. - 2. **Call \`get-stories-by-component\`** with those absolute paths. It returns grounded \`storyId\` values from the live Storybook index, ranked by \`distance\` (0 = colocated, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like \`list-all-documentation\`) are safe to use. - 3. **Bucket by \`distance\`.** Default to \`maxDistance: 2\` and tighten only when you have a reason to. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the \`distance 1\` bucket is often empty — capping at \`1\` would hide the entire cascade. Page-level components can also see surprising \`distance 3+\` matches when Storybook decorators pull in wide swaths of the app; \`maxDistance: 2\` defuses that without losing real consumers. For \`display-review\`, the buckets map directly onto the visual cascade: \`0\` = the component itself, \`1\` = direct importers, \`2+\` (capped via \`maxDistance\`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. + 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is _shared_ infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass _their_ paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made _multiple_ edits in the same session, \`get-changed-stories\` returns the _cumulative_ diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call \`get-stories-by-component\` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. + 2. **Call \`get-stories-by-component\`** with those absolute paths. It returns grounded \`storyId\` values from the live Storybook index, ranked by \`distance\` (0 = the path you passed is itself a story file, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like \`list-all-documentation\`) are safe to use. + 3. **Bucket by \`distance\`.** The tool caps results at \`maxDistance: 3\` by default; lower it to tighten precision when you have a reason to, or raise it to widen recall. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the \`distance 1\` bucket is often empty — capping at \`1\` would hide the entire cascade. Page-level components can also see surprising \`distance 3+\` matches when Storybook decorators pull in wide swaths of the app; the default cap defuses most of that without losing real consumers. For \`display-review\`, the buckets map directly onto the visual cascade: \`0\` = the component itself, \`1\` = direct importers, \`2+\` (capped via \`maxDistance\`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. 4. **Pass the resulting \`storyId\`s into \`preview-stories\`** for preview URLs, or into \`display-review\` for a curated review page (per the visibility guidance above — skip review for changes with no expected visual impact). Never invent story IDs from file names, feature names, or memory — Storybook IDs come from the live index and are the only ones that resolve. If \`get-stories-by-component\` returns no matches for a component, that component genuinely has no stories yet; tell the user rather than fabricating IDs. \`display-review\` validates every ID against the live index server-side and will hard-fail the whole review if any are unknown — there is no "soft" guess that slips through. @@ -86,9 +86,9 @@ describe('buildServerInstructions', () => { Whenever you need story IDs — to preview them, to feed \`display-review\`, to answer the user, for any reason at all — your job is the same regardless of how the request reached you. The input can take any shape: a feature/domain/topic the user named, a file the user mentioned, a file you just edited, a query like "all consumers of X", an autonomous review after a UI change, or anything else. The chain doesn't change with the prompt shape: - 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is *shared* infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass *their* paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made *multiple* edits in the same session, \`get-changed-stories\` returns the *cumulative* diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call \`get-stories-by-component\` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. - 2. **Call \`get-stories-by-component\`** with those absolute paths. It returns grounded \`storyId\` values from the live Storybook index, ranked by \`distance\` (0 = colocated, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like \`list-all-documentation\`) are safe to use. - 3. **Bucket by \`distance\`.** Default to \`maxDistance: 2\` and tighten only when you have a reason to. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the \`distance 1\` bucket is often empty — capping at \`1\` would hide the entire cascade. Page-level components can also see surprising \`distance 3+\` matches when Storybook decorators pull in wide swaths of the app; \`maxDistance: 2\` defuses that without losing real consumers. For \`display-review\`, the buckets map directly onto the visual cascade: \`0\` = the component itself, \`1\` = direct importers, \`2+\` (capped via \`maxDistance\`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. + 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is _shared_ infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass _their_ paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made _multiple_ edits in the same session, \`get-changed-stories\` returns the _cumulative_ diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call \`get-stories-by-component\` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. + 2. **Call \`get-stories-by-component\`** with those absolute paths. It returns grounded \`storyId\` values from the live Storybook index, ranked by \`distance\` (0 = the path you passed is itself a story file, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like \`list-all-documentation\`) are safe to use. + 3. **Bucket by \`distance\`.** The tool caps results at \`maxDistance: 3\` by default; lower it to tighten precision when you have a reason to, or raise it to widen recall. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the \`distance 1\` bucket is often empty — capping at \`1\` would hide the entire cascade. Page-level components can also see surprising \`distance 3+\` matches when Storybook decorators pull in wide swaths of the app; the default cap defuses most of that without losing real consumers. For \`display-review\`, the buckets map directly onto the visual cascade: \`0\` = the component itself, \`1\` = direct importers, \`2+\` (capped via \`maxDistance\`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. 4. **Pass the resulting \`storyId\`s into \`preview-stories\`** for preview URLs, or into \`display-review\` for a curated review page (per the visibility guidance above — skip review for changes with no expected visual impact). Never invent story IDs from file names, feature names, or memory — Storybook IDs come from the live index and are the only ones that resolve. If \`get-stories-by-component\` returns no matches for a component, that component genuinely has no stories yet; tell the user rather than fabricating IDs. \`display-review\` validates every ID against the live index server-side and will hard-fail the whole review if any are unknown — there is no "soft" guess that slips through." @@ -118,9 +118,9 @@ describe('buildServerInstructions', () => { Whenever you need story IDs — to preview them, to feed \`display-review\`, to answer the user, for any reason at all — your job is the same regardless of how the request reached you. The input can take any shape: a feature/domain/topic the user named, a file the user mentioned, a file you just edited, a query like "all consumers of X", an autonomous review after a UI change, or anything else. The chain doesn't change with the prompt shape: - 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is *shared* infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass *their* paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made *multiple* edits in the same session, \`get-changed-stories\` returns the *cumulative* diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call \`get-stories-by-component\` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. - 2. **Call \`get-stories-by-component\`** with those absolute paths. It returns grounded \`storyId\` values from the live Storybook index, ranked by \`distance\` (0 = colocated, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like \`list-all-documentation\`) are safe to use. - 3. **Bucket by \`distance\`.** Default to \`maxDistance: 2\` and tighten only when you have a reason to. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the \`distance 1\` bucket is often empty — capping at \`1\` would hide the entire cascade. Page-level components can also see surprising \`distance 3+\` matches when Storybook decorators pull in wide swaths of the app; \`maxDistance: 2\` defuses that without losing real consumers. For \`display-review\`, the buckets map directly onto the visual cascade: \`0\` = the component itself, \`1\` = direct importers, \`2+\` (capped via \`maxDistance\`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. + 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is _shared_ infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass _their_ paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made _multiple_ edits in the same session, \`get-changed-stories\` returns the _cumulative_ diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call \`get-stories-by-component\` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. + 2. **Call \`get-stories-by-component\`** with those absolute paths. It returns grounded \`storyId\` values from the live Storybook index, ranked by \`distance\` (0 = the path you passed is itself a story file, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like \`list-all-documentation\`) are safe to use. + 3. **Bucket by \`distance\`.** The tool caps results at \`maxDistance: 3\` by default; lower it to tighten precision when you have a reason to, or raise it to widen recall. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the \`distance 1\` bucket is often empty — capping at \`1\` would hide the entire cascade. Page-level components can also see surprising \`distance 3+\` matches when Storybook decorators pull in wide swaths of the app; the default cap defuses most of that without losing real consumers. For \`display-review\`, the buckets map directly onto the visual cascade: \`0\` = the component itself, \`1\` = direct importers, \`2+\` (capped via \`maxDistance\`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. 4. **Pass the resulting \`storyId\`s into \`preview-stories\`** for preview URLs, or into \`display-review\` for a curated review page (per the visibility guidance above — skip review for changes with no expected visual impact). Never invent story IDs from file names, feature names, or memory — Storybook IDs come from the live index and are the only ones that resolve. If \`get-stories-by-component\` returns no matches for a component, that component genuinely has no stories yet; tell the user rather than fabricating IDs. \`display-review\` validates every ID against the live index server-side and will hard-fail the whole review if any are unknown — there is no "soft" guess that slips through." @@ -169,9 +169,9 @@ describe('buildServerInstructions', () => { Whenever you need story IDs — to preview them, to feed \`display-review\`, to answer the user, for any reason at all — your job is the same regardless of how the request reached you. The input can take any shape: a feature/domain/topic the user named, a file the user mentioned, a file you just edited, a query like "all consumers of X", an autonomous review after a UI change, or anything else. The chain doesn't change with the prompt shape: - 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is *shared* infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass *their* paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made *multiple* edits in the same session, \`get-changed-stories\` returns the *cumulative* diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call \`get-stories-by-component\` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. - 2. **Call \`get-stories-by-component\`** with those absolute paths. It returns grounded \`storyId\` values from the live Storybook index, ranked by \`distance\` (0 = colocated, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like \`list-all-documentation\`) are safe to use. - 3. **Bucket by \`distance\`.** Default to \`maxDistance: 2\` and tighten only when you have a reason to. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the \`distance 1\` bucket is often empty — capping at \`1\` would hide the entire cascade. Page-level components can also see surprising \`distance 3+\` matches when Storybook decorators pull in wide swaths of the app; \`maxDistance: 2\` defuses that without losing real consumers. For \`display-review\`, the buckets map directly onto the visual cascade: \`0\` = the component itself, \`1\` = direct importers, \`2+\` (capped via \`maxDistance\`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. + 1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is _shared_ infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass _their_ paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made _multiple_ edits in the same session, \`get-changed-stories\` returns the _cumulative_ diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call \`get-stories-by-component\` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. + 2. **Call \`get-stories-by-component\`** with those absolute paths. It returns grounded \`storyId\` values from the live Storybook index, ranked by \`distance\` (0 = the path you passed is itself a story file, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like \`list-all-documentation\`) are safe to use. + 3. **Bucket by \`distance\`.** The tool caps results at \`maxDistance: 3\` by default; lower it to tighten precision when you have a reason to, or raise it to widen recall. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the \`distance 1\` bucket is often empty — capping at \`1\` would hide the entire cascade. Page-level components can also see surprising \`distance 3+\` matches when Storybook decorators pull in wide swaths of the app; the default cap defuses most of that without losing real consumers. For \`display-review\`, the buckets map directly onto the visual cascade: \`0\` = the component itself, \`1\` = direct importers, \`2+\` (capped via \`maxDistance\`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. 4. **Pass the resulting \`storyId\`s into \`preview-stories\`** for preview URLs, or into \`display-review\` for a curated review page (per the visibility guidance above — skip review for changes with no expected visual impact). Never invent story IDs from file names, feature names, or memory — Storybook IDs come from the live index and are the only ones that resolve. If \`get-stories-by-component\` returns no matches for a component, that component genuinely has no stories yet; tell the user rather than fabricating IDs. \`display-review\` validates every ID against the live index server-side and will hard-fail the whole review if any are unknown — there is no "soft" guess that slips through." diff --git a/packages/addon-mcp/src/instructions/dev-instructions.md b/packages/addon-mcp/src/instructions/dev-instructions.md index db61e848..8856de05 100644 --- a/packages/addon-mcp/src/instructions/dev-instructions.md +++ b/packages/addon-mcp/src/instructions/dev-instructions.md @@ -10,7 +10,7 @@ Whenever you need story IDs — to preview them, to feed `display-review`, to answer the user, for any reason at all — your job is the same regardless of how the request reached you. The input can take any shape: a feature/domain/topic the user named, a file the user mentioned, a file you just edited, a query like "all consumers of X", an autonomous review after a UI change, or anything else. The chain doesn't change with the prompt shape: -1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is *shared* infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass *their* paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made *multiple* edits in the same session, `get-changed-stories` returns the *cumulative* diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call `get-stories-by-component` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. +1. **Identify the relevant component file paths.** Use whatever you have — the user's words, the files you touched, the symbol that changed — and reach a list of absolute paths to component source files using filesystem search (grep / Glob / find) and code reading. The bridge from "whatever the input was" to "a list of component file paths" is yours to build; the tool starts where that bridge ends. One common trap: when the changed file is _shared_ infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass _their_ paths, not the shared file's. If the symbol you greped looks like one member of a related group (sibling tokens, neighboring exports), widen to the rest of the group too — related symbols are often consumed together by different components, and a too-narrow grep silently drops stories. A subtle variant: when you've made _multiple_ edits in the same session, `get-changed-stories` returns the _cumulative_ diff — so a non-empty result may reflect an earlier sub-change and not cover your most recent edit. Always check that every file you've touched is represented in the response; for any that isn't, treat it as the "shared infrastructure" case and call `get-stories-by-component` with its consumers. The tool will surface this gap explicitly with a "coverage sanity check" hint when it detects unreachable working-tree files. 2. **Call `get-stories-by-component`** with those absolute paths. It returns grounded `storyId` values from the live Storybook index, ranked by `distance` (0 = the path you passed is itself a story file, 1 = direct importer, 2+ = transitive). Title strings can be overridden by story authors and don't always match filenames — only IDs from this tool (or other discovery tools like `list-all-documentation`) are safe to use. 3. **Bucket by `distance`.** The tool caps results at `maxDistance: 3` by default; lower it to tighten precision when you have a reason to, or raise it to widen recall. Shared low-level primitives and theme tokens are usually consumed through wrapper components rather than directly from any story file, so the `distance 1` bucket is often empty — capping at `1` would hide the entire cascade. Page-level components can also see surprising `distance 3+` matches when Storybook decorators pull in wide swaths of the app; the default cap defuses most of that without losing real consumers. For `display-review`, the buckets map directly onto the visual cascade: `0` = the component itself, `1` = direct importers, `2+` (capped via `maxDistance`) = page-level context — one collection per layer. When several stories of the same component share a distance, prefer the variant whose name signals it renders the changed surface. 4. **Pass the resulting `storyId`s into `preview-stories`** for preview URLs, or into `display-review` for a curated review page (per the visibility guidance above — skip review for changes with no expected visual impact). diff --git a/packages/addon-mcp/src/mcp-handler.ts b/packages/addon-mcp/src/mcp-handler.ts index dab22f06..40cfa7fa 100644 --- a/packages/addon-mcp/src/mcp-handler.ts +++ b/packages/addon-mcp/src/mcp-handler.ts @@ -43,8 +43,7 @@ const initializeMCPServer = async (options: Options, multiSource?: boolean) => { // the graph runs whenever the dev-server has a supporting builder; `features.changeDetection` // only gates the status pipeline that powers `get-changed-stories`. const dependencyGraphSupported = await isDependencyGraphSupported(); - const changeDetectionEnabled = - (features?.changeDetection ?? false) && dependencyGraphSupported; + const changeDetectionEnabled = (features?.changeDetection ?? false) && dependencyGraphSupported; disableTelemetry = core?.disableTelemetry ?? false; // Determine tool availability before creating server so instructions can be tailored. diff --git a/packages/addon-mcp/src/tools/display-review.ts b/packages/addon-mcp/src/tools/display-review.ts index 99e02881..0696d270 100644 --- a/packages/addon-mcp/src/tools/display-review.ts +++ b/packages/addon-mcp/src/tools/display-review.ts @@ -174,10 +174,7 @@ Always include the returned reviewUrl in your final user-facing response so the // agent gets a reviewUrl back, assumes success, and the user opens // a broken page. Hard-failing here forces the agent to resolve // real IDs via get-stories-by-component before retrying. - const unknownIds = await collectUnknownStoryIds( - input.collections, - customContext.options, - ); + const unknownIds = await collectUnknownStoryIds(input.collections, customContext.options); if (unknownIds.length > 0) { throw new Error(formatUnknownStoryIdsError(unknownIds)); } diff --git a/packages/addon-mcp/src/tools/get-changed-stories.test.ts b/packages/addon-mcp/src/tools/get-changed-stories.test.ts index fe21ec8e..39a407dd 100644 --- a/packages/addon-mcp/src/tools/get-changed-stories.test.ts +++ b/packages/addon-mcp/src/tools/get-changed-stories.test.ts @@ -30,9 +30,7 @@ vi.mock('storybook/internal/core-server', () => ({ })); vi.mock('node:child_process', async () => { - const actual = await vi.importActual( - 'node:child_process', - ); + const actual = await vi.importActual('node:child_process'); return { ...actual, execSync: (...args: unknown[]) => mockExecSync(...(args as [])) }; }); diff --git a/packages/addon-mcp/src/utils/detect-unreachable-changes.test.ts b/packages/addon-mcp/src/utils/detect-unreachable-changes.test.ts index 3ecb08a7..1674979d 100644 --- a/packages/addon-mcp/src/utils/detect-unreachable-changes.test.ts +++ b/packages/addon-mcp/src/utils/detect-unreachable-changes.test.ts @@ -117,9 +117,7 @@ describe('detectUnreachableChanges', () => { mockService({ // reverse-graph knows about Badge.tsx but NOT theme.ts lookup: (dep: string) => - dep.endsWith('Badge.tsx') - ? new Map([['/repo/src/Badge.stories.tsx', 1]]) - : new Map(), + dep.endsWith('Badge.tsx') ? new Map([['/repo/src/Badge.stories.tsx', 1]]) : new Map(), }); mockGit(' M src/styles/theme.ts\n M src/components/Badge/Badge.tsx\n'); const { detectUnreachableChanges } = await import('./detect-unreachable-changes.ts'); diff --git a/packages/addon-mcp/src/utils/format-validation-issues.ts b/packages/addon-mcp/src/utils/format-validation-issues.ts index 0ed73132..64bbd9b6 100644 --- a/packages/addon-mcp/src/utils/format-validation-issues.ts +++ b/packages/addon-mcp/src/utils/format-validation-issues.ts @@ -30,7 +30,6 @@ interface FriendlyIssue { message: string; } - function formatPath(path: StandardSchemaIssue['path']): string { if (!path || path.length === 0) return ''; let out = ''; @@ -86,9 +85,7 @@ function summarizeIssue(issue: StandardSchemaIssue): FriendlyIssue { * `~standard` slot, no prototype methods), so spread-and-replace is enough * — no Proxy required. */ -export function withFriendlyErrors( - schema: TSchema, -): TSchema { +export function withFriendlyErrors(schema: TSchema): TSchema { const original = schema['~standard']; return { ...schema, diff --git a/packages/addon-mcp/src/utils/get-story-index.test.ts b/packages/addon-mcp/src/utils/get-story-index.test.ts index c5aa1d71..6c8093e1 100644 --- a/packages/addon-mcp/src/utils/get-story-index.test.ts +++ b/packages/addon-mcp/src/utils/get-story-index.test.ts @@ -37,9 +37,7 @@ describe('getStoryIndex', () => { const apply = vi.fn().mockResolvedValue(undefined); const options = makeOptions(apply as unknown as Options['presets']['apply']); - await expect(getStoryIndex(options)).rejects.toThrow( - /story index generator is unavailable/, - ); + await expect(getStoryIndex(options)).rejects.toThrow(/story index generator is unavailable/); }); it('propagates errors thrown by the generator', async () => { From 4475fa8d58d6acb9d1098d096a1126648e8948da Mon Sep 17 00:00:00 2001 From: yannbf Date: Tue, 2 Jun 2026 16:36:25 +0200 Subject: [PATCH 7/7] fix(ci): bump Storybook to 10.5.0-alpha.4 for dependency-graph API The get-stories-by-component and get-changed-stories tools require Storybook's experimental_getDependencyGraphService API, which the pinned 10.5.0-alpha.2 doesn't ship but 10.5.0-alpha.4 does. With the old version those tools never registered in CI, so the e2e suite saw 6 tools instead of 8. - Bump all @storybook/* catalog entries to 10.5.0-alpha.4 - Add `subtype: 'story'` to StoryIndexEntry test fixtures (alpha.4's StoryIndexEntry requires it; fixes TS2352 in resolve-component-stories.test.ts) - Refresh the tools/list e2e snapshot for the get-stories-by-component pathNotFound output field Co-Authored-By: Claude Opus 4.8 --- apps/internal-storybook/pnpm-lock.yaml | 151 +++++++++--------- .../tests/mcp-endpoint.e2e.test.ts | 4 + eval/pnpm-lock.yaml | 105 ++++++------ packages/addon-mcp/pnpm-lock.yaml | 44 ++--- .../utils/resolve-component-stories.test.ts | 3 + pnpm-workspace.yaml | 26 +-- 6 files changed, 175 insertions(+), 158 deletions(-) diff --git a/apps/internal-storybook/pnpm-lock.yaml b/apps/internal-storybook/pnpm-lock.yaml index e11dfbc6..4b985082 100644 --- a/apps/internal-storybook/pnpm-lock.yaml +++ b/apps/internal-storybook/pnpm-lock.yaml @@ -7,20 +7,20 @@ settings: catalogs: default: '@storybook/addon-a11y': - specifier: 10.5.0-alpha.2 - version: 10.5.0-alpha.2 + specifier: 10.5.0-alpha.4 + version: 10.5.0-alpha.4 '@storybook/addon-docs': - specifier: 10.5.0-alpha.2 - version: 10.5.0-alpha.2 + specifier: 10.5.0-alpha.4 + version: 10.5.0-alpha.4 '@storybook/addon-themes': - specifier: 10.5.0-alpha.2 - version: 10.5.0-alpha.2 + specifier: 10.5.0-alpha.4 + version: 10.5.0-alpha.4 '@storybook/addon-vitest': - specifier: 10.5.0-alpha.2 - version: 10.5.0-alpha.2 + specifier: 10.5.0-alpha.4 + version: 10.5.0-alpha.4 '@storybook/react-vite': - specifier: 10.5.0-alpha.2 - version: 10.5.0-alpha.2 + specifier: 10.5.0-alpha.4 + version: 10.5.0-alpha.4 '@vitest/browser-playwright': specifier: 4.0.6 version: 4.0.6 @@ -28,8 +28,8 @@ catalogs: specifier: 1.56.1 version: 1.56.1 storybook: - specifier: 10.5.0-alpha.2 - version: 10.5.0-alpha.2 + specifier: 10.5.0-alpha.4 + version: 10.5.0-alpha.4 vite: specifier: 7.2.2 version: 7.2.2 @@ -43,22 +43,22 @@ importers: devDependencies: '@storybook/addon-a11y': specifier: 'catalog:' - version: 10.5.0-alpha.2(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 10.5.0-alpha.4(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@storybook/addon-docs': specifier: 'catalog:' - version: 10.5.0-alpha.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2) + version: 10.5.0-alpha.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2) '@storybook/addon-mcp': specifier: workspace:* version: link:../../packages/addon-mcp '@storybook/addon-themes': specifier: 'catalog:' - version: 10.5.0-alpha.2(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 10.5.0-alpha.4(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.5.0-alpha.2(@vitest/browser-playwright@4.0.6)(@vitest/browser@4.0.6(vite@7.2.2)(vitest@4.0.6))(@vitest/runner@4.0.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vitest@4.0.6) + version: 10.5.0-alpha.4(@vitest/browser-playwright@4.0.6)(@vitest/browser@4.0.6(vite@7.2.2)(vitest@4.0.6))(@vitest/runner@4.0.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vitest@4.0.6) '@storybook/react-vite': specifier: 'catalog:' - version: 10.5.0-alpha.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(esbuild@0.27.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.2.2) + version: 10.5.0-alpha.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(esbuild@0.27.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.2.2) '@types/react': specifier: ^18.2.65 version: 18.3.28 @@ -82,7 +82,7 @@ importers: version: 18.3.1(react@18.3.1) storybook: specifier: 'catalog:' - version: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tinyexec: specifier: ^1.0.2 version: 1.0.2 @@ -937,32 +937,32 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-a11y@10.5.0-alpha.2': - resolution: {integrity: sha512-Nw4DwvekQrGJ01lARHTBg+jDWaRo1+nF1XNP60gtKAdvMdo4u8Rk4sOykT3RfyH3KPj5exq8G9d9S+/gWfWblw==} + '@storybook/addon-a11y@10.5.0-alpha.4': + resolution: {integrity: sha512-xVxAD8GVncJVt7DhQo5khxo1DvMOID0iigY/tZ+dCeu5/yCvrUDYf3sM1x7JLqkWMYlEp3/a3jgngG3JcKLF3w==} peerDependencies: - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 - '@storybook/addon-docs@10.5.0-alpha.2': - resolution: {integrity: sha512-Hd4c35sgLe3CjRtmCD1v/ZURuF8yrWoWVS+r9BcCuwwDPDySQInGHnfGrLOI5X1h0CiVdBJqjLPBbrzagtrkdQ==} + '@storybook/addon-docs@10.5.0-alpha.4': + resolution: {integrity: sha512-0aNngSWnndZrtv0HLEScQr60UPzVSAwfuI3+NYMR/vsZ9kjVrKkEDf+Ggu0CXDD9z3I9f2l5Lr6ETyw+4MPPUw==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 peerDependenciesMeta: '@types/react': optional: true - '@storybook/addon-themes@10.5.0-alpha.2': - resolution: {integrity: sha512-JoNpMnqbUwXLo2+U5JmMar7JQVv00G6v7Eu1M1D/CAo2EN4bKK3g4ovbCjdulXeQ21G8OpVGatai+OVt7SWvhw==} + '@storybook/addon-themes@10.5.0-alpha.4': + resolution: {integrity: sha512-XnNYzZ/gQiKRbJ2eBbDze8bhDEILNJGMRBIBOBmGqFE5C5qKlxTTVLstuE5LIMWwt90g1uNJvyg9c1pGZ0WwrA==} peerDependencies: - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 - '@storybook/addon-vitest@10.5.0-alpha.2': - resolution: {integrity: sha512-agNzDQGXydQVmmAXLN6NkQ6XFo1ONLBLCgcBBoBpKfPZk27EiYxDJNfAilky5cPv/4+jZQL5veZtdkSdKSK2Ag==} + '@storybook/addon-vitest@10.5.0-alpha.4': + resolution: {integrity: sha512-zAVHS3siimPYEfwMgE1JfBsldY0I8h6YFvIINz2aG6VSNyzlaFTBDrwv9jaP6Do0bOpBZGlu9Fd19JkFpzunVA==} peerDependencies: '@vitest/browser': ^3.0.0 || ^4.0.0 '@vitest/browser-playwright': ^4.0.0 '@vitest/runner': ^3.0.0 || ^4.0.0 - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 vitest: ^3.0.0 || ^4.0.0 peerDependenciesMeta: '@vitest/browser': @@ -974,18 +974,18 @@ packages: vitest: optional: true - '@storybook/builder-vite@10.5.0-alpha.2': - resolution: {integrity: sha512-yzjn/TcKNSOXvdn5S0A5RrkSCtsYa0Ath3aL0EVfgkWbPuwHwOD/u3h91CVWRpjGHAOctjhk6cX1nJJQwDx1kQ==} + '@storybook/builder-vite@10.5.0-alpha.4': + resolution: {integrity: sha512-UOrv2Ew7huOvnys6d7tCKy5xPMqYrpwWZ0Ve898uGhZ0E8k0c7eizT0Z99p7Gc6UBNUts/TlC1nAZj1z2hV4yA==} peerDependencies: - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.5.0-alpha.2': - resolution: {integrity: sha512-U2eLlrx3PrzwSbUSa8N1rScUqo+QD3T0yATfneWjiKfpQ5gL1O5dK2U/AbfqIdXzLEgco7NDz8iGx45CtFJlmA==} + '@storybook/csf-plugin@10.5.0-alpha.4': + resolution: {integrity: sha512-Yc/Bg0Ed0ZqVtd+0Z5JzflsT68bgtnjyIV590JiJqGzLi6ebtZ5zjii0C0V4GdFyXlv/0LoK6BABj3LTIhC2wA==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 vite: '*' webpack: '*' peerDependenciesMeta: @@ -1007,36 +1007,40 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/react-dom-shim@10.5.0-alpha.2': - resolution: {integrity: sha512-sgi/5dpDKRKqWCizV1GAbpcz/EjeccHqIq1AlD4kVy+3mKVPMhwwSwvRiq0ihYig+2hhw/fQgHwjamjP2wFHUQ==} + '@storybook/react-dom-shim@10.5.0-alpha.4': + resolution: {integrity: sha512-asFSSLFSP8JndNaTeSzNRor6QsaECYwnkJNehWDFy/Y8rF/A/+ZbAdmypJur+8URcjlXfPbRP+idR0512ZOC+Q==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true - '@storybook/react-vite@10.5.0-alpha.2': - resolution: {integrity: sha512-FYAuy/k6EQxgxFP7eL/t0phhQN/1ksHYE+lr7pGitdC+ZI/QVts2lS576AoLg/Bm58MoSxz3zjDpJ44q7oCQNg==} + '@storybook/react-vite@10.5.0-alpha.4': + resolution: {integrity: sha512-QQz+vLzP79L9zncT6YvCmGX8FCvQ+zKmZNOyM9OmPwepocmsI8oXHrT1DusmolwrOFJwgfwE5iZX+f3YHY1Vpg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 + typescript: '>= 4.9.x' vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true - '@storybook/react@10.5.0-alpha.2': - resolution: {integrity: sha512-ewlAlot/+cvylBaMFbtok1WMlZz4B3ZZ/ebIa9cG8e958pClqVWxvkyA+SZ8oYvJJTNwrcK0HMkaRCdS8RxOMg==} + '@storybook/react@10.5.0-alpha.4': + resolution: {integrity: sha512-TiwxFwClD7RS9Rvtkc5eU9XSEsIrcnOv9iJvT4BX8osN4vKIKi/ZPhh6YCU2tTKhsDDxS7/6auPRYqUx6Bgm+w==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 typescript: '>= 4.9.x' peerDependenciesMeta: '@types/react': @@ -1585,8 +1589,8 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - storybook@10.5.0-alpha.2: - resolution: {integrity: sha512-bxdnOntnglD9loavf1AGELgo36QvIpBFEkypHBHmEEausSZkdyxSHZ4QlC1CTv+5oeg8B7CEo9sGq6JGgIuZzw==} + storybook@10.5.0-alpha.4: + resolution: {integrity: sha512-klHDvxQE6rfDRsdPjUcebopZUWJz9FyZi29wWl6LCUWbsdHxJImmGczg82I035MZwr6NxZUDCFMZSLQGXNuRcw==} hasBin: true peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -2331,21 +2335,21 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-a11y@10.5.0-alpha.2(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': + '@storybook/addon-a11y@10.5.0-alpha.4(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': dependencies: '@storybook/global': 5.0.0 axe-core: 4.11.1 - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@storybook/addon-docs@10.5.0-alpha.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2)': + '@storybook/addon-docs@10.5.0-alpha.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2)': dependencies: '@mdx-js/react': 3.1.1(@types/react@18.3.28)(react@18.3.1) - '@storybook/csf-plugin': 10.5.0-alpha.2(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2) + '@storybook/csf-plugin': 10.5.0-alpha.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2) '@storybook/icons': 2.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@storybook/react-dom-shim': 10.5.0-alpha.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@storybook/react-dom-shim': 10.5.0-alpha.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ts-dedent: 2.2.0 optionalDependencies: '@types/react': 18.3.28 @@ -2356,16 +2360,16 @@ snapshots: - vite - webpack - '@storybook/addon-themes@10.5.0-alpha.2(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': + '@storybook/addon-themes@10.5.0-alpha.4(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': dependencies: - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ts-dedent: 2.2.0 - '@storybook/addon-vitest@10.5.0-alpha.2(@vitest/browser-playwright@4.0.6)(@vitest/browser@4.0.6(vite@7.2.2)(vitest@4.0.6))(@vitest/runner@4.0.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vitest@4.0.6)': + '@storybook/addon-vitest@10.5.0-alpha.4(@vitest/browser-playwright@4.0.6)(@vitest/browser@4.0.6(vite@7.2.2)(vitest@4.0.6))(@vitest/runner@4.0.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vitest@4.0.6)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) optionalDependencies: '@vitest/browser': 4.0.6(vite@7.2.2)(vitest@4.0.6) '@vitest/browser-playwright': 4.0.6(playwright@1.56.1)(vite@7.2.2)(vitest@4.0.6) @@ -2375,10 +2379,10 @@ snapshots: - react - react-dom - '@storybook/builder-vite@10.5.0-alpha.2(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2)': + '@storybook/builder-vite@10.5.0-alpha.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2)': dependencies: - '@storybook/csf-plugin': 10.5.0-alpha.2(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2) - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@storybook/csf-plugin': 10.5.0-alpha.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ts-dedent: 2.2.0 vite: 7.2.2 transitivePeerDependencies: @@ -2386,9 +2390,9 @@ snapshots: - rollup - webpack - '@storybook/csf-plugin@10.5.0-alpha.2(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2)': + '@storybook/csf-plugin@10.5.0-alpha.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2)': dependencies: - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.3 @@ -2402,48 +2406,49 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/react-dom-shim@10.5.0-alpha.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': + '@storybook/react-dom-shim@10.5.0-alpha.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@storybook/react-vite@10.5.0-alpha.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(esbuild@0.27.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.2.2)': + '@storybook/react-vite@10.5.0-alpha.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(esbuild@0.27.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)(vite@7.2.2)': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.2.2) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@storybook/builder-vite': 10.5.0-alpha.2(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2) - '@storybook/react': 10.5.0-alpha.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3) + '@storybook/builder-vite': 10.5.0-alpha.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.2.2) + '@storybook/react': 10.5.0-alpha.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 react: 18.3.1 react-docgen: 8.0.2 react-dom: 18.3.1(react@18.3.1) resolve: 1.22.11 - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tsconfig-paths: 4.2.0 vite: 7.2.2 + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - '@types/react' - '@types/react-dom' - esbuild - rollup - supports-color - - typescript - webpack - '@storybook/react@10.5.0-alpha.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)': + '@storybook/react@10.5.0-alpha.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.0-alpha.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@storybook/react-dom-shim': 10.5.0-alpha.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) react: 18.3.1 react-docgen: 8.0.2 react-docgen-typescript: 2.4.0(typescript@5.9.3) react-dom: 18.3.1(react@18.3.1) - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -3087,7 +3092,7 @@ snapshots: std-env@3.10.0: {} - storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts b/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts index 6d68777e..1d714e8b 100644 --- a/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts +++ b/apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts @@ -474,6 +474,10 @@ describe('MCP Endpoint E2E Tests', () => { }, "type": "array", }, + "pathNotFound": { + "description": "\`true\` when no file exists at the resolved absolute path. Distinguishes a typo from "this component has no stories yet". The agent should re-check the path it sent.", + "type": "boolean", + }, }, "required": [ "componentPath", diff --git a/eval/pnpm-lock.yaml b/eval/pnpm-lock.yaml index a5c8af52..ca235f7f 100644 --- a/eval/pnpm-lock.yaml +++ b/eval/pnpm-lock.yaml @@ -7,17 +7,17 @@ settings: catalogs: default: '@storybook/addon-a11y': - specifier: 10.5.0-alpha.2 - version: 10.5.0-alpha.2 + specifier: 10.5.0-alpha.4 + version: 10.5.0-alpha.4 '@storybook/react-vite': - specifier: 10.5.0-alpha.2 - version: 10.5.0-alpha.2 + specifier: 10.5.0-alpha.4 + version: 10.5.0-alpha.4 playwright: specifier: 1.56.1 version: 1.56.1 storybook: - specifier: 10.5.0-alpha.2 - version: 10.5.0-alpha.2 + specifier: 10.5.0-alpha.4 + version: 10.5.0-alpha.4 valibot: specifier: 1.2.0 version: 1.2.0 @@ -49,13 +49,13 @@ importers: version: 1.1.11(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@storybook/addon-a11y': specifier: 'catalog:' - version: 10.5.0-alpha.2(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 10.5.0-alpha.4(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@storybook/mcp': specifier: workspace:* version: link:../packages/mcp '@storybook/react-vite': specifier: 'catalog:' - version: 10.5.0-alpha.2(@types/react@18.3.28)(esbuild@0.27.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)) + version: 10.5.0-alpha.4(@types/react@18.3.28)(esbuild@0.27.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)) '@tsconfig/node-ts': specifier: ^23.6.1 version: 23.6.3 @@ -127,10 +127,10 @@ importers: version: 5.83.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) storybook: specifier: 'catalog:' - version: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) storybook-addon-test-codegen: specifier: ^3.0.0 - version: 3.0.1(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 3.0.1(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) tinyexec: specifier: ^1.0.1 version: 1.0.2 @@ -1762,23 +1762,23 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@storybook/addon-a11y@10.5.0-alpha.2': - resolution: {integrity: sha512-Nw4DwvekQrGJ01lARHTBg+jDWaRo1+nF1XNP60gtKAdvMdo4u8Rk4sOykT3RfyH3KPj5exq8G9d9S+/gWfWblw==} + '@storybook/addon-a11y@10.5.0-alpha.4': + resolution: {integrity: sha512-xVxAD8GVncJVt7DhQo5khxo1DvMOID0iigY/tZ+dCeu5/yCvrUDYf3sM1x7JLqkWMYlEp3/a3jgngG3JcKLF3w==} peerDependencies: - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 - '@storybook/builder-vite@10.5.0-alpha.2': - resolution: {integrity: sha512-yzjn/TcKNSOXvdn5S0A5RrkSCtsYa0Ath3aL0EVfgkWbPuwHwOD/u3h91CVWRpjGHAOctjhk6cX1nJJQwDx1kQ==} + '@storybook/builder-vite@10.5.0-alpha.4': + resolution: {integrity: sha512-UOrv2Ew7huOvnys6d7tCKy5xPMqYrpwWZ0Ve898uGhZ0E8k0c7eizT0Z99p7Gc6UBNUts/TlC1nAZj1z2hV4yA==} peerDependencies: - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.5.0-alpha.2': - resolution: {integrity: sha512-U2eLlrx3PrzwSbUSa8N1rScUqo+QD3T0yATfneWjiKfpQ5gL1O5dK2U/AbfqIdXzLEgco7NDz8iGx45CtFJlmA==} + '@storybook/csf-plugin@10.5.0-alpha.4': + resolution: {integrity: sha512-Yc/Bg0Ed0ZqVtd+0Z5JzflsT68bgtnjyIV590JiJqGzLi6ebtZ5zjii0C0V4GdFyXlv/0LoK6BABj3LTIhC2wA==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 vite: '*' webpack: '*' peerDependenciesMeta: @@ -1800,36 +1800,40 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/react-dom-shim@10.5.0-alpha.2': - resolution: {integrity: sha512-sgi/5dpDKRKqWCizV1GAbpcz/EjeccHqIq1AlD4kVy+3mKVPMhwwSwvRiq0ihYig+2hhw/fQgHwjamjP2wFHUQ==} + '@storybook/react-dom-shim@10.5.0-alpha.4': + resolution: {integrity: sha512-asFSSLFSP8JndNaTeSzNRor6QsaECYwnkJNehWDFy/Y8rF/A/+ZbAdmypJur+8URcjlXfPbRP+idR0512ZOC+Q==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true - '@storybook/react-vite@10.5.0-alpha.2': - resolution: {integrity: sha512-FYAuy/k6EQxgxFP7eL/t0phhQN/1ksHYE+lr7pGitdC+ZI/QVts2lS576AoLg/Bm58MoSxz3zjDpJ44q7oCQNg==} + '@storybook/react-vite@10.5.0-alpha.4': + resolution: {integrity: sha512-QQz+vLzP79L9zncT6YvCmGX8FCvQ+zKmZNOyM9OmPwepocmsI8oXHrT1DusmolwrOFJwgfwE5iZX+f3YHY1Vpg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 + typescript: '>= 4.9.x' vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true - '@storybook/react@10.5.0-alpha.2': - resolution: {integrity: sha512-ewlAlot/+cvylBaMFbtok1WMlZz4B3ZZ/ebIa9cG8e958pClqVWxvkyA+SZ8oYvJJTNwrcK0HMkaRCdS8RxOMg==} + '@storybook/react@10.5.0-alpha.4': + resolution: {integrity: sha512-TiwxFwClD7RS9Rvtkc5eU9XSEsIrcnOv9iJvT4BX8osN4vKIKi/ZPhh6YCU2tTKhsDDxS7/6auPRYqUx6Bgm+w==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 typescript: '>= 4.9.x' peerDependenciesMeta: '@types/react': @@ -3152,8 +3156,8 @@ packages: peerDependencies: storybook: ^0.0.0-0 || ^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 - storybook@10.5.0-alpha.2: - resolution: {integrity: sha512-bxdnOntnglD9loavf1AGELgo36QvIpBFEkypHBHmEEausSZkdyxSHZ4QlC1CTv+5oeg8B7CEo9sGq6JGgIuZzw==} + storybook@10.5.0-alpha.4: + resolution: {integrity: sha512-klHDvxQE6rfDRsdPjUcebopZUWJz9FyZi29wWl6LCUWbsdHxJImmGczg82I035MZwr6NxZUDCFMZSLQGXNuRcw==} hasBin: true peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -4748,16 +4752,16 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@storybook/addon-a11y@10.5.0-alpha.2(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-a11y@10.5.0-alpha.4(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: '@storybook/global': 5.0.0 axe-core: 4.11.1 - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/builder-vite@10.5.0-alpha.2(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12))': + '@storybook/builder-vite@10.5.0-alpha.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12))': dependencies: - '@storybook/csf-plugin': 10.5.0-alpha.2(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12)) - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@storybook/csf-plugin': 10.5.0-alpha.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12)) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 vite: 7.3.1(@types/node@24.10.12) transitivePeerDependencies: @@ -4765,9 +4769,9 @@ snapshots: - rollup - webpack - '@storybook/csf-plugin@10.5.0-alpha.2(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12))': + '@storybook/csf-plugin@10.5.0-alpha.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12))': dependencies: - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.3 @@ -4781,47 +4785,48 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@storybook/react-dom-shim@10.5.0-alpha.2(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/react-dom-shim@10.5.0-alpha.4(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) optionalDependencies: '@types/react': 18.3.28 - '@storybook/react-vite@10.5.0-alpha.2(@types/react@18.3.28)(esbuild@0.27.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12))': + '@storybook/react-vite@10.5.0-alpha.4(@types/react@18.3.28)(esbuild@0.27.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@storybook/builder-vite': 10.5.0-alpha.2(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12)) - '@storybook/react': 10.5.0-alpha.2(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@storybook/builder-vite': 10.5.0-alpha.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12)) + '@storybook/react': 10.5.0-alpha.4(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 react: 19.2.4 react-docgen: 8.0.2 react-dom: 19.2.4(react@19.2.4) resolve: 1.22.11 - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tsconfig-paths: 4.2.0 vite: 7.3.1(@types/node@24.10.12) + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - '@types/react' - '@types/react-dom' - esbuild - rollup - supports-color - - typescript - webpack - '@storybook/react@10.5.0-alpha.2(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': + '@storybook/react@10.5.0-alpha.4(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.0-alpha.2(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@storybook/react-dom-shim': 10.5.0-alpha.4(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) react: 19.2.4 react-docgen: 8.0.2 react-docgen-typescript: 2.4.0(typescript@5.9.3) react-dom: 19.2.4(react@19.2.4) - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) optionalDependencies: '@types/react': 18.3.28 typescript: 5.9.3 @@ -6222,11 +6227,11 @@ snapshots: space-separated-tokens@2.0.2: {} - storybook-addon-test-codegen@3.0.1(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + storybook-addon-test-codegen@3.0.1(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): dependencies: - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) diff --git a/packages/addon-mcp/pnpm-lock.yaml b/packages/addon-mcp/pnpm-lock.yaml index d2402df6..f4cc163e 100644 --- a/packages/addon-mcp/pnpm-lock.yaml +++ b/packages/addon-mcp/pnpm-lock.yaml @@ -7,11 +7,11 @@ settings: catalogs: default: '@storybook/addon-a11y': - specifier: 10.5.0-alpha.2 - version: 10.5.0-alpha.2 + specifier: 10.5.0-alpha.4 + version: 10.5.0-alpha.4 '@storybook/addon-vitest': - specifier: 10.5.0-alpha.2 - version: 10.5.0-alpha.2 + specifier: 10.5.0-alpha.4 + version: 10.5.0-alpha.4 '@tmcp/adapter-valibot': specifier: ^0.1.5 version: 0.1.5 @@ -19,8 +19,8 @@ catalogs: specifier: ^0.8.5 version: 0.8.5 storybook: - specifier: 10.5.0-alpha.2 - version: 10.5.0-alpha.2 + specifier: 10.5.0-alpha.4 + version: 10.5.0-alpha.4 tmcp: specifier: ^1.19.4 version: 1.19.4 @@ -53,13 +53,13 @@ importers: devDependencies: '@storybook/addon-a11y': specifier: 'catalog:' - version: 10.5.0-alpha.2(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 10.5.0-alpha.4(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.5.0-alpha.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 10.5.0-alpha.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) storybook: specifier: 'catalog:' - version: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) packages: @@ -490,18 +490,18 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-a11y@10.5.0-alpha.2': - resolution: {integrity: sha512-Nw4DwvekQrGJ01lARHTBg+jDWaRo1+nF1XNP60gtKAdvMdo4u8Rk4sOykT3RfyH3KPj5exq8G9d9S+/gWfWblw==} + '@storybook/addon-a11y@10.5.0-alpha.4': + resolution: {integrity: sha512-xVxAD8GVncJVt7DhQo5khxo1DvMOID0iigY/tZ+dCeu5/yCvrUDYf3sM1x7JLqkWMYlEp3/a3jgngG3JcKLF3w==} peerDependencies: - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 - '@storybook/addon-vitest@10.5.0-alpha.2': - resolution: {integrity: sha512-agNzDQGXydQVmmAXLN6NkQ6XFo1ONLBLCgcBBoBpKfPZk27EiYxDJNfAilky5cPv/4+jZQL5veZtdkSdKSK2Ag==} + '@storybook/addon-vitest@10.5.0-alpha.4': + resolution: {integrity: sha512-zAVHS3siimPYEfwMgE1JfBsldY0I8h6YFvIINz2aG6VSNyzlaFTBDrwv9jaP6Do0bOpBZGlu9Fd19JkFpzunVA==} peerDependencies: '@vitest/browser': ^3.0.0 || ^4.0.0 '@vitest/browser-playwright': ^4.0.0 '@vitest/runner': ^3.0.0 || ^4.0.0 - storybook: ^10.5.0-alpha.2 + storybook: ^10.5.0-alpha.4 vitest: ^3.0.0 || ^4.0.0 peerDependenciesMeta: '@vitest/browser': @@ -768,8 +768,8 @@ packages: sqids@0.3.0: resolution: {integrity: sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==} - storybook@10.5.0-alpha.2: - resolution: {integrity: sha512-bxdnOntnglD9loavf1AGELgo36QvIpBFEkypHBHmEEausSZkdyxSHZ4QlC1CTv+5oeg8B7CEo9sGq6JGgIuZzw==} + storybook@10.5.0-alpha.4: + resolution: {integrity: sha512-klHDvxQE6rfDRsdPjUcebopZUWJz9FyZi29wWl6LCUWbsdHxJImmGczg82I035MZwr6NxZUDCFMZSLQGXNuRcw==} hasBin: true peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1084,17 +1084,17 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-a11y@10.5.0-alpha.2(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-a11y@10.5.0-alpha.4(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: '@storybook/global': 5.0.0 axe-core: 4.11.1 - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/addon-vitest@10.5.0-alpha.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-vitest@10.5.0-alpha.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - storybook: 10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - react - react-dom @@ -1397,7 +1397,7 @@ snapshots: sqids@0.3.0: {} - storybook@10.5.0-alpha.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + storybook@10.5.0-alpha.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) diff --git a/packages/addon-mcp/src/utils/resolve-component-stories.test.ts b/packages/addon-mcp/src/utils/resolve-component-stories.test.ts index a59bdd4b..9c328379 100644 --- a/packages/addon-mcp/src/utils/resolve-component-stories.test.ts +++ b/packages/addon-mcp/src/utils/resolve-component-stories.test.ts @@ -43,6 +43,7 @@ function buildStoryIndex(byFile: Record): StoryIndex { for (const id of ids) { entries[id] = { type: 'story', + subtype: 'story', id, name: id, title: id, @@ -139,6 +140,7 @@ describe('resolveComponentStories', () => { entries: { 'a--default': { type: 'story', + subtype: 'story', id: 'a--default', name: 'Default', title: 'A', @@ -147,6 +149,7 @@ describe('resolveComponentStories', () => { } as StoryIndex['entries'][string], 'virtual--page': { type: 'story', + subtype: 'story', id: 'virtual--page', name: 'Virtual', title: 'V', diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 670e8662..13f3e8ac 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,19 +6,19 @@ packages: - '!eval/tasks/*/trials/*/project' catalog: - '@storybook/addon-a11y': 10.5.0-alpha.2 - '@storybook/addon-docs': 10.5.0-alpha.2 - '@storybook/addon-themes': 10.5.0-alpha.2 - '@storybook/addon-vitest': 10.5.0-alpha.2 - '@storybook/react-vite': 10.5.0-alpha.2 + '@storybook/addon-a11y': 10.5.0-alpha.4 + '@storybook/addon-docs': 10.5.0-alpha.4 + '@storybook/addon-themes': 10.5.0-alpha.4 + '@storybook/addon-vitest': 10.5.0-alpha.4 + '@storybook/react-vite': 10.5.0-alpha.4 '@types/semver': 7.7.1 '@tmcp/adapter-valibot': ^0.1.5 '@tmcp/transport-http': ^0.8.5 '@tmcp/transport-stdio': ^0.4.3 '@vitest/browser-playwright': 4.0.6 - eslint-plugin-storybook: 10.5.0-alpha.2 + eslint-plugin-storybook: 10.5.0-alpha.4 playwright: 1.56.1 - storybook: 10.5.0-alpha.2 + storybook: 10.5.0-alpha.4 tmcp: ^1.19.4 semver: 7.8.1 tsdown: ^0.15.12 @@ -30,10 +30,10 @@ catalog: catalogs: trials: '@eslint/js': 9.39.1 - '@storybook/addon-a11y': 10.5.0-alpha.2 - '@storybook/addon-docs': 10.5.0-alpha.2 - '@storybook/addon-vitest': 10.5.0-alpha.2 - '@storybook/react-vite': 10.5.0-alpha.2 + '@storybook/addon-a11y': 10.5.0-alpha.4 + '@storybook/addon-docs': 10.5.0-alpha.4 + '@storybook/addon-vitest': 10.5.0-alpha.4 + '@storybook/react-vite': 10.5.0-alpha.4 '@types/node': 24.10.1 '@types/react': 19.2.6 '@types/react-dom': 19.2.3 @@ -42,11 +42,11 @@ catalogs: eslint: 9.39.1 eslint-plugin-react-hooks: 7.0.1 eslint-plugin-react-refresh: 0.4.24 - eslint-plugin-storybook: 10.5.0-alpha.2 + eslint-plugin-storybook: 10.5.0-alpha.4 globals: 16.5.0 react: 19.2.0 react-dom: 19.2.0 - storybook: 10.5.0-alpha.2 + storybook: 10.5.0-alpha.4 typescript: 5.9.3 typescript-eslint: 8.47.0 vite: 7.2.2