diff --git a/README.md b/README.md index 1854cfa..94fa48b 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ It keeps them in sync continuously. | **Reconciled** | Design overrides, code markers, AST values, and defaults are merged with explicit precedence: `override > marker > ast > code`. | | **Auditable** | Every operation produces artifacts. Every decision is traceable. CI gates enforce drift thresholds. | | **Safe** | Dry-run by default. Opt-in writes. Echo suppression prevents feedback loops. Rollback previews before destructive changes. | -| **Read-only adapters** | External integrations (e.g., Figma MCP, Storybook) are read-only with default-deny tool policies. Adapters are classified by surface type, access mode, authority role, and stability. AF is the only mutation authority. | +| **Read-only adapters** | External integrations (Figma MCP, Storybook MCP) are read-only with default-deny tool policies. Adapters are classified by surface type, access mode, authority role, and stability. AF is the only mutation authority. | ### How It Differs From… @@ -106,6 +106,7 @@ It keeps them in sync continuously. | **Prompt-to-code** (v0, Bolt, etc.) | AF doesn't generate code from designs. It *reconciles* code and design as a continuous system. | | **Design token export** (Style Dictionary, etc.) | AF goes beyond tokens — it syncs component structure, variants, states, and properties bidirectionally. | | **MCP integrations** (figma-console-mcp, etc.) | AF uses MCP as a *read-only data source*, never as a mutation path. AF's control plane is watcher → server → plugin. | +| **Storybook MCP** (@storybook/addon-mcp) | AF connects to Storybook's MCP endpoint to read component metadata for cross-surface drift analysis — it does not run or control Storybook. | | **Figma plugins** (code-gen plugins) | AF's plugin is a *mutation executor*, not a decision-maker. Reconciliation happens in the watcher. | ## Architecture @@ -200,6 +201,7 @@ Available profiles: `designer-first`, `code-first`, `balanced`, `strict-review`. | `af design pull` | Pull design data (tokens + components + styles) | | `af design screenshot` | Capture design screenshot | | `af design component [name]` | List or inspect components | +| `af design drift [name]` | Cross-surface drift analysis (Figma vs Storybook vs code) | ## Project Structure @@ -208,10 +210,14 @@ aesthetic-function/ ├── packages/ │ ├── shared/ # Protocol definitions, shared types │ ├── watcher/ # Reconciliation engine, AST analysis, adapters +│ │ └── src/ +│ │ ├── designAdapter/ # Figma + Storybook MCP adapters +│ │ └── crossSurfaceDrift/ # Cross-surface drift analysis engine │ ├── server/ # WebSocket/HTTP relay, audit logging │ ├── figma-plugin/ # Figma sandbox plugin (mutation executor) │ └── cli/ # `af` CLI control surface -├── demo-app/ # Sample React app with @figma markers +├── demo-app/ # Sample React app with @figma markers + Storybook +│ └── .storybook/ # Storybook config (addon-mcp enabled) ├── docs/ │ └── architecture-reference.md # Full internal reference ├── .github/ @@ -228,6 +234,8 @@ aesthetic-function/ | `FIGMA_FILE_KEY` | — | Figma file key | | `USE_LLM_ANALYZER` | `false` | Enable LLM intent parsing (optional) | | `TRACE` | `false` | Enable trace logging | +| `STORYBOOK_URL` | `http://localhost:6006` | Storybook dev server URL | +| `STORYBOOK_ENABLED` | `false` | Enable Storybook MCP adapter | See [docs/architecture-reference.md](docs/architecture-reference.md) for the complete environment variable reference. diff --git a/claude.md b/claude.md index 9372f76..7950b7d 100644 --- a/claude.md +++ b/claude.md @@ -22,6 +22,12 @@ This repository implements an AI-driven Code → Design synchronization system. - ui.html: network allowed - code.ts: NO network, NO filesystem +4. Storybook Dev Server (optional, Phase 16C) + - Runs on http://localhost:6006 (configurable) + - Exposes MCP endpoint via @storybook/addon-mcp at /mcp + - Read-only data source for cross-surface drift analysis + - Start with: `pnpm dev:storybook` + ## Protocol Rules - All cross-process communication uses shared TypeScript interfaces - Single canonical protocol file: `/packages/shared/src/protocol.ts` @@ -33,6 +39,12 @@ This repository implements an AI-driven Code → Design synchronization system. - Never mix explanation with structured output - Retry with repair prompts if validation fails +## Design Adapter Rules +- Adapters are read-only with default-deny tool policies +- Storybook MCP adapter: `af design drift [component]` for cross-surface analysis +- Storybook adapter requires dev server running (`pnpm dev:storybook`) +- Cross-surface drift is a separate analysis pass — it does NOT modify reconciliation + ## Design Token Rules - Prefer semantic tokens over raw values - Token resolution happens before Figma operations diff --git a/demo-app/.storybook/main.ts b/demo-app/.storybook/main.ts new file mode 100644 index 0000000..ff80ad5 --- /dev/null +++ b/demo-app/.storybook/main.ts @@ -0,0 +1,15 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(ts|tsx)'], + addons: [ + '@storybook/addon-essentials', + '@storybook/addon-mcp', + ], + framework: '@storybook/react-vite', + features: { + componentsManifest: true, + }, +}; + +export default config; diff --git a/demo-app/.storybook/preview.ts b/demo-app/.storybook/preview.ts new file mode 100644 index 0000000..adcda96 --- /dev/null +++ b/demo-app/.storybook/preview.ts @@ -0,0 +1,14 @@ +import type { Preview } from '@storybook/react'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +}; + +export default preview; diff --git a/demo-app/package.json b/demo-app/package.json index ad2e7d1..ddd3b27 100644 --- a/demo-app/package.json +++ b/demo-app/package.json @@ -2,5 +2,23 @@ "name": "demo-app", "version": "0.1.0", "private": true, - "description": "Demo React app with @figma markers for testing the watcher" + "description": "Demo React app with @figma markers for testing the watcher", + "scripts": { + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@storybook/addon-essentials": "^8.6.14", + "@storybook/addon-mcp": "^0.5.0", + "@storybook/react": "^8.6.14", + "@storybook/react-vite": "^8.6.14", + "storybook": "^8.6.14", + "typescript": "^5.3.3", + "@types/react": "^18.3.20", + "@types/react-dom": "^18.3.7" + } } diff --git a/demo-app/src/stories/App.stories.tsx b/demo-app/src/stories/App.stories.tsx new file mode 100644 index 0000000..aa2bbc8 --- /dev/null +++ b/demo-app/src/stories/App.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DemoButton, TestBox, WelcomeHeading } from '../App'; + +// ============================================================================= +// DEMO BUTTON +// ============================================================================= + +const buttonMeta: Meta = { + title: 'Components/DemoButton', + component: DemoButton, +}; + +export default buttonMeta; + +type ButtonStory = StoryObj; + +export const Default: ButtonStory = {}; + +// ============================================================================= +// TEST BOX +// ============================================================================= + +export const Box: StoryObj = { + render: () => , +}; + +// ============================================================================= +// WELCOME HEADING +// ============================================================================= + +export const Heading: StoryObj = { + render: () => , +}; diff --git a/demo-app/src/stories/Card.stories.tsx b/demo-app/src/stories/Card.stories.tsx new file mode 100644 index 0000000..2ca0d1b --- /dev/null +++ b/demo-app/src/stories/Card.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Card, SuccessButton, ErrorButton } from '../Card'; + +// ============================================================================= +// CARD +// ============================================================================= + +const cardMeta: Meta = { + title: 'Components/Card', + component: Card, + args: { + title: 'Card Title', + children: 'Card content goes here.', + }, +}; + +export default cardMeta; + +type CardStory = StoryObj; + +export const Default: CardStory = {}; + +export const WithLongTitle: CardStory = { + args: { title: 'A Very Long Card Title That Wraps' }, +}; + +// ============================================================================= +// SUCCESS BUTTON (co-located for simplicity) +// ============================================================================= + +export const Success: StoryObj = { + render: () => , +}; + +// ============================================================================= +// ERROR BUTTON (co-located for simplicity) +// ============================================================================= + +export const Error: StoryObj = { + render: () => , +}; diff --git a/docs/adapter-model.md b/docs/adapter-model.md index b0ef564..d3c8804 100644 --- a/docs/adapter-model.md +++ b/docs/adapter-model.md @@ -13,6 +13,7 @@ AF uses design adapters to read data from external design systems. Adapters are |---------|-----------|------------|-------| | **Figma REST** | HTTP (Figma API) | Tokens, components, styles | 16A | | **Figma Console MCP** | stdio / SSE / REST fallback | Screenshots, component inspection | 16B | +| **Storybook MCP** | StreamableHTTP / SSE / HTTP fallback | Component metadata, stories, props | 16C | ## Safety Model @@ -21,6 +22,51 @@ AF uses design adapters to read data from external design systems. Adapters are - The MCP adapter validates tool names against an allow-list before every call - Blocked tools are rejected with a descriptive error, never silently dropped +## Storybook MCP Adapter (Phase 16C) + +The Storybook MCP adapter connects to `@storybook/addon-mcp` running inside a local Storybook dev server. It enables **cross-surface drift analysis** — comparing component metadata across Figma, Storybook, and code AST. + +### Transport + +Connects via HTTP to the Storybook dev server's `/mcp` endpoint (not stdio). Falls back to direct HTTP (`/manifests/components.json`) if MCP is unavailable. + +### Operating Modes + +| Mode | Condition | Capabilities | +|------|-----------|-------------| +| `mcp` | MCP endpoint responds | Full: component metadata, stories, props, screenshots | +| `http-fallback` | Server up, MCP unavailable | Reduced: component metadata from manifest only | +| `unavailable` | Server unreachable | None — `isAvailable()` returns false | + +The capability manifest (`getCapabilities()`) reflects the actual operating mode. MCP-only capabilities (e.g., `readScreenshots`) report `false` in HTTP-fallback mode. + +### Tool Policy + +Same default-deny pattern as Figma Console MCP: + +| Tool | Status | Reason | +|------|--------|--------| +| `list-all-documentation` | Allowed | Read-only component listing | +| `get-documentation` | Allowed | Read-only component metadata | +| `get-documentation-for-story` | Allowed | Read-only story metadata | +| `get-storybook-story-instructions` | Allowed | Read-only story instructions | +| `run-story-tests` | **Blocked** | Side-effects (test execution) | +| `preview-stories` | **Blocked** | May trigger renders | +| *(any unlisted tool)* | **Blocked** | Default-deny | + +### Framework Guard + +The adapter validates that the Storybook instance is React-based. Non-React frameworks (Vue, Angular, Svelte) cause `isAvailable()` to return `false` with an explicit error. + +### Cross-Surface Drift Analysis + +The `af design drift` command compares component data across available surfaces: +- **Figma** — variants, properties from Figma adapter +- **Storybook** — props, stories, variant axes from Storybook MCP +- **Code** — props, union types from AST analysis + +Drift findings use **corroboration rules** to filter noise: a story-derived variant is only reported if (1) the variant axis maps to a real prop, (2) the value appears in the prop's type definition. Findings carry `confidence: 'high'` (constrained union match) or `'low'` (unconstrained type like `string`). + ## Detailed Reference -The full adapter model, including MCP transport configuration, tool policies, and Phase 16A/16B implementation details, is documented in [architecture-reference.md](architecture-reference.md). +The full adapter model, including MCP transport configuration, tool policies, and Phase 16A/16B/16C implementation details, is documented in [architecture-reference.md](architecture-reference.md). diff --git a/docs/architecture-reference.md b/docs/architecture-reference.md index c58d18b..e148c19 100644 --- a/docs/architecture-reference.md +++ b/docs/architecture-reference.md @@ -534,6 +534,7 @@ The system follows a **three-legged stool** design with strict runtime boundarie | **Phase 16A** | Design Adapter Interface (verification-scoped) | ✅ | | **Phase 16A.1** | Surface Classification Metadata (adapter taxonomy) | ✅ | | **Phase 16B** | Figma Console MCP Adapter (read-only) | ✅ | +| **Phase 16C** | Storybook MCP Adapter + Cross-Surface Drift Analysis | ✅ | ### Not Implemented Yet @@ -557,7 +558,7 @@ The reconciliation system is **feature-complete through Phase 14F**. Key capabil - **Unified Reconcile CLI (14A–14F)**: `figma:reconcile` entry point, profiles (local/record/ci), bundle artifacts, GitHub Actions matrix workflow, multi-source discovery - **Configuration & Profiles (15A–15B)**: `af.config.json`, named policy profiles (designer-first, code-first, balanced, strict-review) - **CLI & Inspector (15C–15D)**: Unified `af` CLI (control surface, not runtime), artifact listing/inspection/trace -- **Design Adapters (16A–16B)**: Read-only design adapter interface, Figma Console MCP adapter, surface classification metadata (taxonomy layer for adapter categorization) +- **Design Adapters (16A–16C)**: Read-only design adapter interface, Figma Console MCP adapter, Storybook MCP adapter, surface classification metadata, cross-surface drift analysis Echo suppression prevents feedback loops when AST writes trigger file save events. diff --git a/package.json b/package.json index a1ea91e..8874aff 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "demo:watcher": "echo '👁 Starting watcher...' && pnpm --filter @aesthetic-function/watcher watch", "demo:feature": "echo '🎯 Running feature orchestrator...' && pnpm --filter @aesthetic-function/watcher cli:feature", "demo:fast": "echo '⚡ Quick demo: server + watcher + feature (use --prompt)' && concurrently -n server,watcher 'pnpm demo:server' 'sleep 2 && pnpm demo:watcher'", - "demo:tunnel": "echo '🚇 Expose server for Figma plugin...' && pnpm tunnel && echo 'Copy the HTTPS URL to Figma plugin settings'" + "demo:tunnel": "echo '🚇 Expose server for Figma plugin...' && pnpm tunnel && echo 'Copy the HTTPS URL to Figma plugin settings'", + "dev:storybook": "cd demo-app && npx storybook dev -p 6006" }, "engines": { "node": ">=18.0.0", diff --git a/packages/cli/src/commands/design.ts b/packages/cli/src/commands/design.ts index 539f31e..c1f29f8 100644 --- a/packages/cli/src/commands/design.ts +++ b/packages/cli/src/commands/design.ts @@ -23,6 +23,7 @@ const SUBCOMMANDS: Record = { inspect: 'designAdapter/cliDesignInspect.ts', screenshot: 'designAdapter/cliDesignScreenshot.ts', component: 'designAdapter/cliDesignComponent.ts', + drift: 'crossSurfaceDrift/cliCrossSurfaceDrift.ts', }; function printHelp(): void { @@ -37,6 +38,7 @@ Subcommands: inspect --all Inspect all design components screenshot Capture a design screenshot component [name] List or inspect design components + drift [component] Cross-surface drift analysis (Figma vs Storybook vs Code) Options (all subcommands): --json Output JSON format @@ -50,7 +52,9 @@ Examples: af design inspect Button af design inspect --all --verbose af design screenshot --node 1:100 - af design component Button`); + af design component Button + af design drift Button + af design drift --json --include-uncorroborated`); } export async function design(args: string[]): Promise { @@ -65,7 +69,7 @@ export async function design(args: string[]): Promise { const modulePath = SUBCOMMANDS[subcommand]; if (!modulePath) { console.error(`Unknown design subcommand: ${subcommand}`); - console.error('Valid subcommands: pull, tokens, inspect, screenshot, component'); + console.error('Valid subcommands: pull, tokens, inspect, screenshot, component, drift'); return 2; } diff --git a/packages/shared/package.json b/packages/shared/package.json index 72026fb..9332517 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -34,6 +34,18 @@ "./designAdapter": { "types": "./dist/designAdapter.d.ts", "import": "./dist/designAdapter.js" + }, + "./surfaceMetadata": { + "types": "./dist/surfaceMetadata.d.ts", + "import": "./dist/surfaceMetadata.js" + }, + "./storybookAdapter": { + "types": "./dist/storybookAdapter.d.ts", + "import": "./dist/storybookAdapter.js" + }, + "./crossSurfaceDrift": { + "types": "./dist/crossSurfaceDrift.d.ts", + "import": "./dist/crossSurfaceDrift.js" } }, "scripts": { diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 3ffac7b..5058337 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -149,6 +149,25 @@ export interface AfConfig { /** Enable audit logging to sync-log.md. Default: false */ enabled?: boolean; }; + + /** + * Storybook MCP adapter configuration (Phase 16C). + * + * Connects to @storybook/addon-mcp running inside the Storybook dev server + * to pull structured component metadata for cross-surface drift analysis. + */ + storybook?: { + /** URL of the Storybook dev server (e.g., http://localhost:6006). Default: http://localhost:6006 */ + url?: string; + /** MCP endpoint path. Default: '/mcp' */ + mcpPath?: string; + /** Request timeout in ms. Default: 30000 */ + timeout?: number; + /** Enable/disable the Storybook adapter. Default: false */ + enabled?: boolean; + /** Expected framework. Adapter validates at startup and rejects non-matching. Default: 'react' */ + framework?: 'react'; + }; } // ============================================================================= @@ -194,6 +213,14 @@ export interface ResolvedAfConfig { enabled: boolean; }; + storybook: { + url: string; + mcpPath: string; + timeout: number; + enabled: boolean; + framework: 'react'; + }; + /** Where the config was loaded from, or null if using defaults */ _source: string | null; } diff --git a/packages/shared/src/configLoader.ts b/packages/shared/src/configLoader.ts index 5e5f1a4..ae9ab7e 100644 --- a/packages/shared/src/configLoader.ts +++ b/packages/shared/src/configLoader.ts @@ -70,6 +70,13 @@ export const DEFAULT_CONFIG: ResolvedAfConfig = { audit: { enabled: false, }, + storybook: { + url: 'http://localhost:6006', + mcpPath: '/mcp', + timeout: 30000, + enabled: false, + framework: 'react', + }, _source: null, }; @@ -254,6 +261,27 @@ function validateAfConfig(raw: Record): AfConfig { } } + // Storybook (Phase 16C) + if (typeof raw.storybook === 'object' && raw.storybook !== null && !Array.isArray(raw.storybook)) { + const sb = raw.storybook as Record; + config.storybook = {}; + if (typeof sb.url === 'string' && sb.url.length > 0) { + config.storybook.url = sb.url; + } + if (typeof sb.mcpPath === 'string' && sb.mcpPath.length > 0) { + config.storybook.mcpPath = sb.mcpPath; + } + if (typeof sb.timeout === 'number' && sb.timeout > 0) { + config.storybook.timeout = sb.timeout; + } + if (typeof sb.enabled === 'boolean') { + config.storybook.enabled = sb.enabled; + } + if (sb.framework === 'react') { + config.storybook.framework = sb.framework; + } + } + return config; } @@ -353,6 +381,17 @@ function applyEnvOverrides(base: ResolvedAfConfig): ResolvedAfConfig { config.audit.enabled = false; } + // Storybook + if (process.env.STORYBOOK_URL) { + config.storybook.url = process.env.STORYBOOK_URL; + } + const storybookEnabled = process.env.STORYBOOK_ENABLED?.toLowerCase(); + if (storybookEnabled === 'true' || storybookEnabled === '1') { + config.storybook.enabled = true; + } else if (storybookEnabled === 'false' || storybookEnabled === '0') { + config.storybook.enabled = false; + } + return config; } @@ -396,6 +435,13 @@ function mergeFileConfig(defaults: ResolvedAfConfig, file: AfConfig, source: str // Audit if (file.audit?.enabled !== undefined) config.audit.enabled = file.audit.enabled; + // Storybook + if (file.storybook?.url !== undefined) config.storybook.url = file.storybook.url; + if (file.storybook?.mcpPath !== undefined) config.storybook.mcpPath = file.storybook.mcpPath; + if (file.storybook?.timeout !== undefined) config.storybook.timeout = file.storybook.timeout; + if (file.storybook?.enabled !== undefined) config.storybook.enabled = file.storybook.enabled; + if (file.storybook?.framework !== undefined) config.storybook.framework = file.storybook.framework; + return config; } diff --git a/packages/shared/src/crossSurfaceDrift.ts b/packages/shared/src/crossSurfaceDrift.ts new file mode 100644 index 0000000..9fb8b55 --- /dev/null +++ b/packages/shared/src/crossSurfaceDrift.ts @@ -0,0 +1,141 @@ +/** + * @aesthetic-function/shared - crossSurfaceDrift.ts + * + * Phase 16C: Cross-Surface Drift Analysis Types. + * + * WHY: When AF has data from multiple surfaces (Figma, Storybook, code AST), + * it can detect parity gaps between them. This module defines the types for + * that analysis. Cross-surface drift is a SEPARATE read-only pass — it does + * NOT modify reconciliation resolution (Phase 14F semantics are frozen). + * + * Think of it as: reconciliation decides field values; drift analysis reports + * where surfaces disagree. + */ + +// ============================================================================= +// DRIFT REPORT +// ============================================================================= + +/** + * Report from comparing a single component across multiple surfaces. + */ +export interface CrossSurfaceDriftReport { + /** Component name being compared */ + componentName: string; + + /** Surfaces that were compared (null = surface unavailable) */ + surfaces: { + figma?: SurfaceSnapshot; + storybook?: SurfaceSnapshot; + code?: SurfaceSnapshot; + }; + + /** Individual drift findings */ + findings: DriftFinding[]; + + /** Overall drift severity (highest finding wins) */ + severity: DriftSeverity; + + /** When the analysis was performed */ + analyzedAt: string; +} + +// ============================================================================= +// SURFACE SNAPSHOT +// ============================================================================= + +/** + * A point-in-time snapshot of a component as seen from one surface. + */ +export interface SurfaceSnapshot { + /** Adapter ID or source identifier */ + source: string; + + /** Component name as seen by this surface */ + componentName: string; + + /** Props visible from this surface */ + props: SurfaceProp[]; + + /** Variant values visible from this surface */ + variants: string[]; + + /** When this snapshot was taken */ + lastObserved: string; +} + +/** + * A prop as seen from a surface. + */ +export interface SurfaceProp { + name: string; + type?: string; + values?: string[]; +} + +// ============================================================================= +// DRIFT FINDING +// ============================================================================= + +/** + * A single drift finding — a specific disagreement between surfaces. + */ +export interface DriftFinding { + /** What was compared (e.g., "prop:variant", "variant:ghost") */ + field: string; + + /** Type of drift */ + type: DriftType; + + /** Severity of this finding */ + severity: DriftSeverity; + + /** Human-readable description of the drift */ + message: string; + + /** Value from Figma surface (if available) */ + figmaValue?: string; + + /** Value from Storybook surface (if available) */ + storybookValue?: string; + + /** Value from code surface (if available) */ + codeValue?: string; + + /** Optional Storybook story reference for the mismatched item */ + storyRef?: string; + + /** + * How confident we are this is real drift, not noise. + * - 'high': prop exists AND value is in a constrained union type + * - 'low': prop exists but type is unconstrained (e.g., string) + */ + confidence: DriftConfidence; +} + +// ============================================================================= +// ENUMS +// ============================================================================= + +export type DriftType = + | 'missing-in-figma' + | 'missing-in-storybook' + | 'missing-in-code' + | 'value-mismatch' + | 'name-mismatch'; + +export type DriftSeverity = 'none' | 'info' | 'warn' | 'fail'; + +export type DriftConfidence = 'high' | 'low'; + +// ============================================================================= +// ANALYSIS OPTIONS +// ============================================================================= + +/** + * Options for the drift analysis engine. + */ +export interface DriftAnalysisOptions { + /** Include uncorroborated story-derived variants in findings (default: false) */ + includeUncorroborated?: boolean; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 122fb24..27acc68 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -11,6 +11,8 @@ export * from './config.js'; export * from './policy.js'; export * from './designAdapter.js'; export * from './surfaceMetadata.js'; +export * from './storybookAdapter.js'; +export * from './crossSurfaceDrift.js'; export { loadAfConfig, findConfigFile, diff --git a/packages/shared/src/storybookAdapter.ts b/packages/shared/src/storybookAdapter.ts new file mode 100644 index 0000000..e3b4ac0 --- /dev/null +++ b/packages/shared/src/storybookAdapter.ts @@ -0,0 +1,148 @@ +/** + * @aesthetic-function/shared - storybookAdapter.ts + * + * Phase 16C: Storybook MCP Adapter Types. + * + * WHY: The Storybook MCP adapter returns structured component metadata that + * doesn't map 1:1 to the existing DesignAdapter interface (which was designed + * for design tools like Figma). These types capture Storybook-specific data: + * props with types/defaults, stories, variant axes, and docs linkage. + * + * CRITICAL CONSTRAINTS: + * - These types are for READ-ONLY data extraction from Storybook. + * - No mutation types. No write operations. + * - Data flows: Storybook MCP → adapter → normalization → drift analysis. + */ + +// ============================================================================= +// STORYBOOK COMPONENT METADATA +// ============================================================================= + +/** + * Metadata for a single component as extracted from Storybook's component manifest. + * + * This corresponds to the `ComponentManifest` type from @storybook/mcp, + * normalized into AF's structure. + */ +export interface StorybookComponentMeta { + /** Component name (e.g., "Button", "Card") */ + name: string; + + /** Component ID in Storybook manifest (e.g., "button") */ + id: string; + + /** Import path if available (e.g., "import { Button } from './Button'") */ + importPath?: string; + + /** Component description from JSDoc or docs */ + description?: string; + + /** Props with types, defaults, required status */ + props: StorybookProp[]; + + /** Stories associated with this component */ + stories: StorybookStory[]; + + /** Docs page reference if available */ + docsUrl?: string; +} + +/** + * A single prop extracted from Storybook's reactDocgen / argTypes. + */ +export interface StorybookProp { + /** Prop name (e.g., "variant", "size", "onClick") */ + name: string; + + /** + * Type string as reported by react-docgen. + * May be a union (e.g., "'primary' | 'secondary' | 'ghost'"), + * a primitive (e.g., "string", "boolean"), or complex (e.g., "ReactNode"). + */ + type: string; + + /** Default value as a string, if specified */ + defaultValue?: string; + + /** Whether this prop is required */ + required: boolean; + + /** Prop description from JSDoc */ + description?: string; +} + +/** + * A single Storybook story. + */ +export interface StorybookStory { + /** Story ID in Storybook (e.g., "button--primary") */ + id: string; + + /** Story display name (e.g., "Primary") */ + name: string; + + /** Code snippet showing how the story uses the component */ + snippet?: string; + + /** + * Variant dimensions this story encodes. + * Inferred from story name + args matching prop names. + * e.g., { variant: 'primary', size: 'large' } + */ + variantAxes?: Record; +} + +// ============================================================================= +// STORYBOOK INVENTORY +// ============================================================================= + +/** + * Full inventory of components and stories from a Storybook instance. + */ +export interface StorybookInventory { + /** All components discovered in Storybook */ + components: StorybookComponentMeta[]; + + /** Total number of stories across all components */ + totalStories: number; + + /** Storybook version if detectable */ + storybookVersion?: string; + + /** Whether the manifest API was available (vs HTTP fallback) */ + manifestAvailable: boolean; +} + +// ============================================================================= +// STORYBOOK ADAPTER CONFIG +// ============================================================================= + +/** + * Configuration for the Storybook MCP Adapter. + */ +export interface StorybookMCPConfig { + /** URL of the Storybook dev server (e.g., http://localhost:6006) */ + url: string; + + /** MCP endpoint path (default: '/mcp') */ + mcpPath?: string; + + /** Request timeout in ms (default: 30000) */ + timeout?: number; + + /** Expected framework. Adapter validates at startup. Default: 'react' */ + framework?: 'react'; +} + +// ============================================================================= +// OPERATING MODE +// ============================================================================= + +/** + * The adapter's current operating mode. + * + * - 'mcp': Connected to @storybook/addon-mcp via MCP protocol + * - 'http-fallback': MCP unavailable, using direct HTTP to manifest endpoints + * - 'unavailable': Storybook dev server not reachable or framework mismatch + */ +export type StorybookOperatingMode = 'mcp' | 'http-fallback' | 'unavailable'; diff --git a/packages/watcher/src/crossSurfaceDrift/__tests__/analyze.test.ts b/packages/watcher/src/crossSurfaceDrift/__tests__/analyze.test.ts new file mode 100644 index 0000000..ddf99f4 --- /dev/null +++ b/packages/watcher/src/crossSurfaceDrift/__tests__/analyze.test.ts @@ -0,0 +1,272 @@ +/** + * @aesthetic-function/watcher - crossSurfaceDrift/__tests__/analyze.test.ts + * + * Phase 16C: Tests for cross-surface drift analysis engine. + */ + +import { describe, it, expect } from 'vitest'; +import { analyzeCrossSurfaceDrift } from '../analyze.js'; +import type { CodeSurfaceData } from '../analyze.js'; +import type { NormalizedDesignComponent } from '../../designAdapter/types.js'; +import type { StorybookComponentMeta } from '@aesthetic-function/shared/storybookAdapter'; + +// ============================================================================= +// TEST HELPERS +// ============================================================================= + +function makeFigmaComponent(overrides?: Partial): NormalizedDesignComponent { + return { + name: 'Button', + nodeId: 'figma:1:100', + type: 'component', + properties: {}, + unmappedProperties: [], + variants: [ + { name: 'Primary', nodeId: '1:101', state: 'primary' }, + { name: 'Secondary', nodeId: '1:102', state: 'secondary' }, + ], + ...overrides, + }; +} + +function makeStorybookComponent(overrides?: Partial): StorybookComponentMeta { + return { + name: 'Button', + id: 'button', + props: [ + { name: 'variant', type: "'primary' | 'secondary' | 'ghost'", required: false }, + { name: 'size', type: "'small' | 'medium' | 'large'", required: false }, + { name: 'children', type: 'ReactNode', required: true }, + ], + stories: [ + { id: 'button--primary', name: 'Primary', variantAxes: { variant: 'primary' } }, + { id: 'button--secondary', name: 'Secondary', variantAxes: { variant: 'secondary' } }, + { id: 'button--ghost', name: 'Ghost', variantAxes: { variant: 'ghost' } }, + ], + ...overrides, + }; +} + +function makeCodeData(overrides?: Partial): CodeSurfaceData { + return { + props: ['variant', 'size', 'children', 'onClick'], + variants: ['primary', 'secondary', 'ghost'], + ...overrides, + }; +} + +// ============================================================================= +// COMPONENT PRESENCE +// ============================================================================= + +describe('component presence', () => { + it('no drift when component exists in all surfaces', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', + makeFigmaComponent(), + makeStorybookComponent(), + makeCodeData(), + ); + // No component-level findings (presence OK) + const presenceFindings = report.findings.filter(f => f.field === 'component'); + expect(presenceFindings).toHaveLength(0); + }); + + it('reports missing-in-figma when component only in Storybook', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', + null, + makeStorybookComponent(), + makeCodeData(), + ); + const finding = report.findings.find(f => f.type === 'missing-in-figma' && f.field === 'component'); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('warn'); + }); + + it('reports missing-in-storybook when component only in Figma', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', + makeFigmaComponent(), + null, + makeCodeData(), + ); + const finding = report.findings.find(f => f.type === 'missing-in-storybook' && f.field === 'component'); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('info'); + }); +}); + +// ============================================================================= +// PROP INVENTORY +// ============================================================================= + +describe('prop inventory', () => { + it('detects props missing from Figma', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', + makeFigmaComponent({ properties: {} }), + makeStorybookComponent(), + null, + ); + const propFindings = report.findings.filter(f => f.field.startsWith('prop:')); + expect(propFindings.length).toBeGreaterThan(0); + }); +}); + +// ============================================================================= +// VARIANT COVERAGE +// ============================================================================= + +describe('variant coverage', () => { + it('detects Figma missing a variant that Storybook has', () => { + // Figma has Primary and Secondary, Storybook has Primary, Secondary, Ghost + const report = analyzeCrossSurfaceDrift( + 'Button', + makeFigmaComponent(), + makeStorybookComponent(), + makeCodeData(), + ); + const ghostFinding = report.findings.find( + f => f.field.includes('ghost') && f.type === 'missing-in-figma', + ); + expect(ghostFinding).toBeDefined(); + expect(ghostFinding!.severity).toBe('warn'); + expect(ghostFinding!.storyRef).toBe('button--ghost'); + }); + + it('no variant drift when all surfaces match', () => { + const figma = makeFigmaComponent({ + variants: [ + { name: 'Primary', nodeId: '1:101', state: 'primary' }, + { name: 'Secondary', nodeId: '1:102', state: 'secondary' }, + { name: 'Ghost', nodeId: '1:103', state: 'ghost' }, + ], + }); + const report = analyzeCrossSurfaceDrift( + 'Button', + figma, + makeStorybookComponent(), + makeCodeData(), + ); + const variantDrift = report.findings.filter( + f => f.field.startsWith('variant:') && f.type === 'missing-in-figma', + ); + expect(variantDrift).toHaveLength(0); + }); +}); + +// ============================================================================= +// CORROBORATION +// ============================================================================= + +describe('corroboration rules', () => { + it('high confidence when variant value exists in constrained union type', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', + makeFigmaComponent({ variants: [] }), + makeStorybookComponent({ + props: [{ name: 'size', type: "'small' | 'medium' | 'large'", required: false }], + stories: [{ id: 'button--large', name: 'Large', variantAxes: { size: 'large' } }], + }), + null, + ); + const finding = report.findings.find(f => f.field.includes('size') && f.field.includes('large')); + expect(finding).toBeDefined(); + expect(finding!.confidence).toBe('high'); + }); + + it('low confidence when prop type is unconstrained (string)', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', + makeFigmaComponent({ variants: [] }), + makeStorybookComponent({ + props: [{ name: 'label', type: 'string', required: false }], + stories: [{ id: 'button--hello', name: 'Hello', variantAxes: { label: 'hello' } }], + }), + null, + ); + // Find the variant-specific finding (not the prop inventory finding) + const finding = report.findings.find(f => f.field.includes('variant:') && f.field.includes('label')); + expect(finding).toBeDefined(); + expect(finding!.confidence).toBe('low'); + }); + + it('uncorroborated variants excluded by default', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', + makeFigmaComponent({ variants: [] }), + makeStorybookComponent({ + props: [], + stories: [{ id: 'button--loading', name: 'Loading', variantAxes: { state: 'loading' } }], + }), + null, + ); + const loadingFinding = report.findings.find(f => f.field.includes('loading')); + expect(loadingFinding).toBeUndefined(); + }); + + it('uncorroborated variants included with includeUncorroborated option', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', + makeFigmaComponent({ variants: [] }), + makeStorybookComponent({ + props: [], + stories: [{ id: 'button--loading', name: 'Loading', variantAxes: { state: 'loading' } }], + }), + null, + { includeUncorroborated: true }, + ); + const loadingFinding = report.findings.find(f => f.field.includes('loading')); + expect(loadingFinding).toBeDefined(); + expect(loadingFinding!.severity).toBe('info'); + expect(loadingFinding!.confidence).toBe('low'); + }); +}); + +// ============================================================================= +// SEVERITY AGGREGATION +// ============================================================================= + +describe('severity aggregation', () => { + it('severity is "none" when no findings', () => { + const report = analyzeCrossSurfaceDrift('Button', null, null, null); + expect(report.severity).toBe('none'); + }); + + it('severity is highest finding severity', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', + makeFigmaComponent(), + makeStorybookComponent(), + makeCodeData(), + ); + // Should have at least warn-level findings (missing ghost variant in Figma) + expect(['warn', 'fail']).toContain(report.severity); + }); +}); + +// ============================================================================= +// REPORT METADATA +// ============================================================================= + +describe('report metadata', () => { + it('includes component name and timestamp', () => { + const report = analyzeCrossSurfaceDrift('Button', null, makeStorybookComponent(), null); + expect(report.componentName).toBe('Button'); + expect(report.analyzedAt).toBeTruthy(); + expect(new Date(report.analyzedAt).getTime()).not.toBeNaN(); + }); + + it('includes surface snapshots for available surfaces', () => { + const report = analyzeCrossSurfaceDrift( + 'Button', + makeFigmaComponent(), + makeStorybookComponent(), + null, + ); + expect(report.surfaces.figma).toBeDefined(); + expect(report.surfaces.storybook).toBeDefined(); + expect(report.surfaces.code).toBeUndefined(); + }); +}); diff --git a/packages/watcher/src/crossSurfaceDrift/analyze.ts b/packages/watcher/src/crossSurfaceDrift/analyze.ts new file mode 100644 index 0000000..c2d56a0 --- /dev/null +++ b/packages/watcher/src/crossSurfaceDrift/analyze.ts @@ -0,0 +1,479 @@ +/** + * @aesthetic-function/watcher - crossSurfaceDrift/analyze.ts + * + * Phase 16C: Cross-Surface Drift Analysis Engine. + * + * WHY: When AF has component data from multiple surfaces (Figma, Storybook, + * code AST), this engine compares them and reports parity gaps. This is a + * SEPARATE read-only pass — it does NOT modify reconciliation resolution. + * + * The existing precedence stack (override > marker > ast > code) is frozen + * at Phase 14F. Cross-surface comparison runs AFTER reconciliation and + * produces an informational report, not a precedence layer. + */ + +import type { + CrossSurfaceDriftReport, + DriftFinding, + DriftSeverity, + DriftConfidence, + SurfaceSnapshot, + SurfaceProp, + DriftAnalysisOptions, +} from '@aesthetic-function/shared/crossSurfaceDrift'; +import type { StorybookComponentMeta, StorybookProp } from '@aesthetic-function/shared/storybookAdapter'; +import type { NormalizedDesignComponent } from '../designAdapter/types.js'; + +// ============================================================================= +// CORE ANALYSIS +// ============================================================================= + +/** + * Input data for the code surface (from AST analysis). + */ +export interface CodeSurfaceData { + /** Prop names found in the component's TypeScript/JSX */ + props: string[]; + /** Variant values (from union types, e.g., 'primary' | 'secondary') */ + variants: string[]; +} + +/** + * Analyze cross-surface drift for a single component. + * + * Compares component data from up to three surfaces: + * - Figma (design) — from FigmaConsoleMCPAdapter + * - Storybook (code-adjacent) — from StorybookMCPAdapter + * - Code (AST) — from watcher's AST analysis + * + * Returns findings about where surfaces disagree. + */ +export function analyzeCrossSurfaceDrift( + componentName: string, + figmaData: NormalizedDesignComponent | null, + storybookData: StorybookComponentMeta | null, + codeData: CodeSurfaceData | null, + options?: DriftAnalysisOptions, +): CrossSurfaceDriftReport { + const findings: DriftFinding[] = []; + const now = new Date().toISOString(); + + // Build surface snapshots + const surfaces: CrossSurfaceDriftReport['surfaces'] = {}; + + if (figmaData) { + surfaces.figma = buildFigmaSnapshot(figmaData); + } + if (storybookData) { + surfaces.storybook = buildStorybookSnapshot(storybookData); + } + if (codeData) { + surfaces.code = buildCodeSnapshot(componentName, codeData); + } + + // Run comparisons + findings.push(...compareComponentPresence(componentName, surfaces)); + findings.push(...comparePropInventory(surfaces)); + findings.push( + ...compareVariantCoverage(surfaces, storybookData, options), + ); + + // Compute severity (highest finding wins) + const severity = computeOverallSeverity(findings); + + return { + componentName, + surfaces, + findings, + severity, + analyzedAt: now, + }; +} + +// ============================================================================= +// SNAPSHOT BUILDERS +// ============================================================================= + +function buildFigmaSnapshot(data: NormalizedDesignComponent): SurfaceSnapshot { + const props: SurfaceProp[] = []; + const variants: string[] = []; + + // Extract variants from the normalized component + // NormalizedDesignComponent variants have { name, nodeId, state } + if (data.variants) { + for (const variant of data.variants) { + variants.push(variant.name); + if (variant.state) { + props.push({ name: 'state', values: [variant.state] }); + } + } + } + + // Extract property-based props + if (data.properties) { + for (const key of Object.keys(data.properties)) { + const value = data.properties[key as keyof typeof data.properties]; + if (value !== undefined && !props.some(p => p.name === key)) { + props.push({ name: key, type: typeof value === 'string' ? value : undefined }); + } + } + } + + return { + source: 'figma-console-mcp', + componentName: data.name, + props: deduplicateProps(props), + variants, + lastObserved: new Date().toISOString(), + }; +} + +function buildStorybookSnapshot(data: StorybookComponentMeta): SurfaceSnapshot { + const props: SurfaceProp[] = data.props.map(p => ({ + name: p.name, + type: p.type, + values: extractUnionValues(p.type), + })); + + const variants: string[] = []; + for (const story of data.stories) { + if (story.variantAxes) { + for (const value of Object.values(story.variantAxes)) { + if (!variants.includes(value)) { + variants.push(value); + } + } + } + } + + return { + source: 'storybook-mcp', + componentName: data.name, + props: deduplicateProps(props), + variants, + lastObserved: new Date().toISOString(), + }; +} + +function buildCodeSnapshot(name: string, data: CodeSurfaceData): SurfaceSnapshot { + return { + source: 'code-ast', + componentName: name, + props: data.props.map(p => ({ name: p })), + variants: data.variants, + lastObserved: new Date().toISOString(), + }; +} + +// ============================================================================= +// COMPARISON FUNCTIONS +// ============================================================================= + +/** + * Check if the component exists in all available surfaces. + */ +function compareComponentPresence( + componentName: string, + surfaces: CrossSurfaceDriftReport['surfaces'], +): DriftFinding[] { + const findings: DriftFinding[] = []; + const availableSurfaces = Object.keys(surfaces).filter( + k => surfaces[k as keyof typeof surfaces] != null, + ); + + if (availableSurfaces.length < 2) { + // Need at least 2 surfaces to compare + return findings; + } + + if (!surfaces.figma && surfaces.storybook) { + findings.push({ + field: 'component', + type: 'missing-in-figma', + severity: 'warn', + message: `Component "${componentName}" exists in Storybook but not found in Figma`, + storybookValue: componentName, + confidence: 'high', + }); + } + + if (surfaces.figma && !surfaces.storybook) { + findings.push({ + field: 'component', + type: 'missing-in-storybook', + severity: 'info', + message: `Component "${componentName}" exists in Figma but not found in Storybook`, + figmaValue: componentName, + confidence: 'high', + }); + } + + if (!surfaces.code && (surfaces.figma || surfaces.storybook)) { + findings.push({ + field: 'component', + type: 'missing-in-code', + severity: 'warn', + message: `Component "${componentName}" exists in design surfaces but not found in code`, + confidence: 'high', + }); + } + + return findings; +} + +/** + * Compare prop names across surfaces. + */ +function comparePropInventory( + surfaces: CrossSurfaceDriftReport['surfaces'], +): DriftFinding[] { + const findings: DriftFinding[] = []; + + // Collect all prop names from each surface + const figmaProps = new Set( + surfaces.figma?.props.map(p => p.name.toLowerCase()) ?? [], + ); + const storybookProps = new Set( + surfaces.storybook?.props.map(p => p.name.toLowerCase()) ?? [], + ); + const codeProps = new Set( + surfaces.code?.props.map(p => p.name.toLowerCase()) ?? [], + ); + + // Find props in Storybook but not in Figma + if (surfaces.storybook && surfaces.figma) { + for (const prop of storybookProps) { + if (!figmaProps.has(prop)) { + findings.push({ + field: `prop:${prop}`, + type: 'missing-in-figma', + severity: 'info', + message: `Prop "${prop}" present in Storybook but not in Figma component properties`, + storybookValue: prop, + confidence: 'high', + }); + } + } + } + + // Find props in Figma but not in Storybook + if (surfaces.figma && surfaces.storybook) { + for (const prop of figmaProps) { + if (!storybookProps.has(prop)) { + findings.push({ + field: `prop:${prop}`, + type: 'missing-in-storybook', + severity: 'info', + message: `Prop "${prop}" present in Figma but not in Storybook`, + figmaValue: prop, + confidence: 'high', + }); + } + } + } + + // Find props in code but not in Figma + if (surfaces.code && surfaces.figma) { + for (const prop of codeProps) { + if (!figmaProps.has(prop) && !storybookProps.has(prop)) { + findings.push({ + field: `prop:${prop}`, + type: 'missing-in-figma', + severity: 'info', + message: `Prop "${prop}" present in Code but not in Figma or Storybook`, + codeValue: prop, + confidence: 'high', + }); + } + } + } + + return findings; +} + +/** + * Compare variant coverage across surfaces. + * Uses corroboration rules to filter noise from decorative stories. + */ +function compareVariantCoverage( + surfaces: CrossSurfaceDriftReport['surfaces'], + storybookData: StorybookComponentMeta | null, + options?: DriftAnalysisOptions, +): DriftFinding[] { + const findings: DriftFinding[] = []; + + if (!surfaces.storybook || !storybookData) return findings; + + // Collect Figma variant values (case-normalized) + const figmaVariants = new Set( + (surfaces.figma?.variants ?? []).map(v => v.toLowerCase()), + ); + + // Collect code variant values (case-normalized) + const codeVariants = new Set( + (surfaces.code?.variants ?? []).map(v => v.toLowerCase()), + ); + + // Process each Storybook story's variant axes + for (const story of storybookData.stories) { + if (!story.variantAxes) continue; + + for (const [propName, value] of Object.entries(story.variantAxes)) { + // Corroboration check: does the prop exist in the component? + const matchingProp = storybookData.props.find( + p => p.name.toLowerCase() === propName.toLowerCase(), + ); + + if (!matchingProp) { + // Uncorroborated: story claims a variant axis but no matching prop + if (options?.includeUncorroborated) { + findings.push({ + field: `variant:${propName}:${value}`, + type: 'missing-in-figma', + severity: 'info', + message: `Story "${story.name}" has uncorroborated variant axis {${propName}: "${value}"} — no matching prop in component manifest`, + storybookValue: value, + storyRef: story.id, + confidence: 'low', + }); + } + continue; + } + + // Determine confidence based on prop type + const confidence = determineConfidence(matchingProp, value); + + // Check if Figma has this variant + const normalizedValue = value.toLowerCase(); + if (surfaces.figma && !figmaVariants.has(normalizedValue)) { + findings.push({ + field: `variant:${propName}:${value}`, + type: 'missing-in-figma', + severity: 'warn', + message: `Storybook has ${propName}="${value}" but Figma is missing this variant`, + storybookValue: value, + storyRef: story.id, + confidence, + }); + } + + // Check if code has this variant + if (surfaces.code && !codeVariants.has(normalizedValue)) { + findings.push({ + field: `variant:${propName}:${value}`, + type: 'missing-in-code', + severity: 'info', + message: `Storybook has ${propName}="${value}" but not found in code variants`, + storybookValue: value, + storyRef: story.id, + confidence, + }); + } + } + } + + // Check Figma variants missing from Storybook + if (surfaces.figma) { + const storybookVariantValues = new Set(); + for (const story of storybookData.stories) { + if (story.variantAxes) { + for (const value of Object.values(story.variantAxes)) { + storybookVariantValues.add(value.toLowerCase()); + } + } + } + + for (const variant of surfaces.figma.variants) { + if (!storybookVariantValues.has(variant.toLowerCase())) { + findings.push({ + field: `variant:${variant}`, + type: 'missing-in-storybook', + severity: 'info', + message: `Figma has variant "${variant}" but no matching Storybook story found`, + figmaValue: variant, + confidence: 'high', + }); + } + } + } + + return findings; +} + +// ============================================================================= +// CORROBORATION HELPERS +// ============================================================================= + +/** + * Determine confidence level for a variant finding. + * + * - 'high': prop type is a constrained union and the value appears in it + * - 'low': prop type is unconstrained (e.g., string) + */ +function determineConfidence(prop: StorybookProp, value: string): DriftConfidence { + const unionValues = extractUnionValues(prop.type); + + if (unionValues.length === 0) { + // Unconstrained type (e.g., "string", "any") + return 'low'; + } + + // Check if value appears in the union + const normalizedValue = value.toLowerCase(); + const found = unionValues.some(v => v.toLowerCase() === normalizedValue); + return found ? 'high' : 'low'; +} + +// ============================================================================= +// UTILITY HELPERS +// ============================================================================= + +/** + * Extract string literal values from a union type. + * "'primary' | 'secondary' | 'ghost'" → ['primary', 'secondary', 'ghost'] + */ +function extractUnionValues(typeStr: string): string[] { + const matches = typeStr.match(/'([^']+)'/g); + if (!matches) return []; + return matches.map(m => m.replace(/'/g, '')); +} + +/** + * Compute the highest severity from a list of findings. + */ +function computeOverallSeverity(findings: DriftFinding[]): DriftSeverity { + if (findings.length === 0) return 'none'; + + const severityOrder: DriftSeverity[] = ['none', 'info', 'warn', 'fail']; + let highest = 0; + + for (const f of findings) { + const idx = severityOrder.indexOf(f.severity); + if (idx > highest) highest = idx; + } + + return severityOrder[highest]; +} + +/** + * Deduplicate props by name (case-insensitive), merging values. + */ +function deduplicateProps(props: SurfaceProp[]): SurfaceProp[] { + const map = new Map(); + for (const prop of props) { + const key = prop.name.toLowerCase(); + const existing = map.get(key); + if (existing) { + // Merge values + if (prop.values) { + existing.values = [...(existing.values ?? []), ...prop.values]; + } + if (prop.type && !existing.type) { + existing.type = prop.type; + } + } else { + map.set(key, { ...prop }); + } + } + return Array.from(map.values()); +} diff --git a/packages/watcher/src/crossSurfaceDrift/cliCrossSurfaceDrift.ts b/packages/watcher/src/crossSurfaceDrift/cliCrossSurfaceDrift.ts new file mode 100644 index 0000000..5c91574 --- /dev/null +++ b/packages/watcher/src/crossSurfaceDrift/cliCrossSurfaceDrift.ts @@ -0,0 +1,278 @@ +/** + * @aesthetic-function/watcher - crossSurfaceDrift/cliCrossSurfaceDrift.ts + * + * Phase 16C: CLI entry point for `af design drift` command. + * + * Runs cross-surface drift analysis comparing component data from + * Figma, Storybook, and code (AST). Produces a human-readable or + * JSON report of parity gaps between surfaces. + * + * This is a READ-ONLY operation — it does not modify reconciliation + * resolution or write to any surface. + */ + +import type { CrossSurfaceDriftReport } from '@aesthetic-function/shared/crossSurfaceDrift'; +import type { StorybookMCPConfig } from '@aesthetic-function/shared/storybookAdapter'; +import { loadAfConfig } from '@aesthetic-function/shared/configLoader'; +import { StorybookMCPAdapter } from '../designAdapter/storybookAdapter.js'; +import { getAvailableAdapter } from '../designAdapter/registry.js'; +import { normalizeDesignComponent } from '../designAdapter/normalize.js'; +import { analyzeCrossSurfaceDrift } from './analyze.js'; +import type { CodeSurfaceData } from './analyze.js'; + +// ============================================================================= +// CLI ENTRY POINT +// ============================================================================= + +interface CliOptions { + componentName?: string; + json: boolean; + verbose: boolean; + includeUncorroborated: boolean; +} + +function parseArgs(args: string[]): CliOptions { + const options: CliOptions = { + json: false, + verbose: false, + includeUncorroborated: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--json') { + options.json = true; + } else if (arg === '--verbose' || arg === '-v') { + options.verbose = true; + } else if (arg === '--include-uncorroborated') { + options.includeUncorroborated = true; + } else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else if (!arg.startsWith('-') && !options.componentName) { + options.componentName = arg; + } + } + + return options; +} + +function printHelp(): void { + console.log(`af design drift — Cross-surface drift analysis (read-only) + +Usage: af design drift [component-name] [options] + +Compares component metadata across Figma, Storybook, and code to detect +parity gaps. This is a read-only analysis — it does not modify reconciliation. + +Arguments: + component-name Component to analyze (optional; analyzes all if omitted) + +Options: + --json Output JSON format + --verbose, -v Verbose output with trace details + --include-uncorroborated Include uncorroborated story-derived variants + -h, --help Show this help + +Examples: + af design drift Button + af design drift Card --json + af design drift --include-uncorroborated`); +} + +export async function main(args: string[]): Promise { + const options = parseArgs(args); + const config = loadAfConfig(); + + // Check adapter availability + const figmaAdapter = await getAvailableAdapter(); + const storybookConfig: StorybookMCPConfig = { + url: config.storybook.url, + mcpPath: config.storybook.mcpPath, + timeout: config.storybook.timeout, + framework: config.storybook.framework, + }; + const storybookAdapter = new StorybookMCPAdapter(storybookConfig); + const storybookAvailable = await storybookAdapter.isAvailable(); + + // Report surface availability + if (!options.json) { + const figmaStatus = figmaAdapter ? `available` : 'unavailable'; + const storybookStatus = storybookAvailable + ? `available (${storybookAdapter.operatingMode})` + : 'unavailable'; + + if (!storybookAvailable && !figmaAdapter) { + console.log(`\u2717 Cross-Surface Drift Analysis: aborted\n`); + console.log(`Figma adapter: unavailable`); + console.log(`Storybook adapter: unavailable`); + if (storybookAdapter.unavailableReason) { + console.log(` \u2192 ${storybookAdapter.unavailableReason}`); + console.log(` \u2192 Start it with: pnpm dev:storybook`); + console.log(` \u2192 Or configure a different URL in af.config.json \u2192 storybook.url`); + } + console.log(`\nCannot run drift analysis without at least 2 surfaces. Exiting.`); + return 2; + } + + if (!storybookAvailable) { + console.log(`Storybook adapter: unavailable`); + if (storybookAdapter.unavailableReason) { + console.log(` \u2192 ${storybookAdapter.unavailableReason}`); + console.log(` \u2192 Start it with: pnpm dev:storybook`); + } + console.log(`Continuing with Figma + Code only.\n`); + } + + if (options.verbose) { + console.log(`Figma adapter: ${figmaStatus}`); + console.log(`Storybook adapter: ${storybookStatus}\n`); + } + } + + // Get component data from available surfaces + const reports: CrossSurfaceDriftReport[] = []; + + if (options.componentName) { + // Analyze a single component + const report = await analyzeComponent( + options.componentName, + figmaAdapter, + storybookAvailable ? storybookAdapter : null, + options, + ); + reports.push(report); + } else { + // Analyze all components from Storybook inventory + if (storybookAvailable) { + const inventory = await storybookAdapter.getInventory(); + for (const component of inventory.data.components) { + const report = await analyzeComponent( + component.name, + figmaAdapter, + storybookAdapter, + options, + ); + reports.push(report); + } + } + } + + // Output + if (options.json) { + console.log(JSON.stringify(reports, null, 2)); + } else { + for (const report of reports) { + formatReport(report, options.verbose); + } + } + + // Return exit code based on severity + const hasFailures = reports.some(r => r.severity === 'fail'); + return hasFailures ? 1 : 0; +} + +// ============================================================================= +// COMPONENT ANALYSIS +// ============================================================================= + +async function analyzeComponent( + componentName: string, + figmaAdapter: Awaited>, + storybookAdapter: StorybookMCPAdapter | null, + options: CliOptions, +): Promise { + // Fetch Figma data + let figmaData = null; + if (figmaAdapter) { + try { + const result = await figmaAdapter.getComponent(componentName); + if (result.data) { + figmaData = normalizeDesignComponent(result.data); + } + } catch { + // Figma data unavailable for this component + } + } + + // Fetch Storybook data + let storybookData = null; + if (storybookAdapter) { + try { + const result = await storybookAdapter.getComponentMeta(componentName); + storybookData = result.data; + } catch { + // Storybook data unavailable for this component + } + } + + // Code data would come from AST analysis — placeholder for now + const codeData: CodeSurfaceData | null = null; + + return analyzeCrossSurfaceDrift( + componentName, + figmaData, + storybookData, + codeData, + { includeUncorroborated: options.includeUncorroborated }, + ); +} + +// ============================================================================= +// HUMAN-READABLE FORMATTING +// ============================================================================= + +function formatReport(report: CrossSurfaceDriftReport, verbose: boolean): void { + console.log(`Cross-Surface Drift Analysis: ${report.componentName}`); + console.log('\u2501'.repeat(40)); + + // Surface status + const surfaces: string[] = []; + if (report.surfaces.figma) surfaces.push('Figma \u2713'); + else surfaces.push('Figma \u2717'); + if (report.surfaces.storybook) surfaces.push('Storybook \u2713'); + else surfaces.push('Storybook \u2717'); + if (report.surfaces.code) surfaces.push('Code (AST) \u2713'); + else surfaces.push('Code (AST) \u2717'); + + console.log(`Surfaces: ${surfaces.join(' ')}`); + console.log(''); + + if (report.findings.length === 0) { + console.log('No drift detected.'); + } else { + console.log('Findings:'); + for (const finding of report.findings) { + const icon = finding.severity === 'warn' ? '\u26A0' : + finding.severity === 'fail' ? '\u2717' : '\u2139'; + const severityLabel = finding.severity.toUpperCase(); + console.log(` ${icon} ${severityLabel} ${finding.field} \u2014 ${finding.message} [confidence: ${finding.confidence}]`); + if (finding.storyRef && verbose) { + console.log(` \u2192 Story ref: ${finding.storyRef}`); + } + } + } + + console.log(''); + + // Summary + const warnCount = report.findings.filter(f => f.severity === 'warn').length; + const infoCount = report.findings.filter(f => f.severity === 'info').length; + const failCount = report.findings.filter(f => f.severity === 'fail').length; + + if (report.findings.length > 0) { + const parts: string[] = []; + if (failCount > 0) parts.push(`${failCount} failure${failCount !== 1 ? 's' : ''}`); + if (warnCount > 0) parts.push(`${warnCount} warning${warnCount !== 1 ? 's' : ''}`); + if (infoCount > 0) parts.push(`${infoCount} info`); + console.log(`Severity: ${report.severity} (${parts.join(', ')})`); + } + console.log(''); +} + +// Allow direct execution via tsx +if (process.argv[1]?.endsWith('cliCrossSurfaceDrift.ts') || process.argv[1]?.endsWith('cliCrossSurfaceDrift.js')) { + main(process.argv.slice(2)).then(code => { + process.exitCode = code; + }); +} diff --git a/packages/watcher/src/designAdapter/__tests__/storybookAdapter.test.ts b/packages/watcher/src/designAdapter/__tests__/storybookAdapter.test.ts index 67cfdf0..6073744 100644 --- a/packages/watcher/src/designAdapter/__tests__/storybookAdapter.test.ts +++ b/packages/watcher/src/designAdapter/__tests__/storybookAdapter.test.ts @@ -1,22 +1,72 @@ /** * @aesthetic-function/watcher - designAdapter/__tests__/storybookAdapter.test.ts * - * Phase 16A Extension: Tests for Storybook Adapter stub. + * Phase 16C: Tests for StorybookMCPAdapter. + * + * Tests: + * - Identity and version + * - Surface metadata classification + * - Capability manifest (reflects operating mode, write blocking) + * - BLOCKED/ALLOWED tool registries + * - isAvailable behavior (dev server unreachable, framework guard) + * - AdapterResult shape compliance + * + * NOTE: These tests do NOT call a real Storybook server. They test the adapter's + * structure, capability enforcement, and error handling. */ import { describe, it, expect } from 'vitest'; +import { + StorybookMCPAdapter, + BLOCKED_STORYBOOK_TOOLS, + BLOCKED_STORYBOOK_TOOL_NAMES, + ALLOWED_STORYBOOK_TOOLS, + ALLOWED_STORYBOOK_TOOL_NAMES, +} from '../storybookAdapter.js'; +import type { StorybookMCPConfig } from '@aesthetic-function/shared/storybookAdapter'; + +// ============================================================================= +// CONFIG HELPERS +// ============================================================================= + +function makeConfig(overrides?: Partial): StorybookMCPConfig { + return { + url: 'http://localhost:6006', + mcpPath: '/mcp', + timeout: 5000, + framework: 'react', + ...overrides, + }; +} -import { StorybookAdapter } from '../storybookAdapter.js'; +// ============================================================================= +// IDENTITY +// ============================================================================= -describe('StorybookAdapter', () => { - const adapter = new StorybookAdapter(); +describe('StorybookMCPAdapter identity', () => { + const adapter = new StorybookMCPAdapter(makeConfig()); - it('has correct id and display name', () => { - expect(adapter.id).toBe('storybook'); - expect(adapter.displayName).toBe('Storybook Adapter'); + it('has correct id', () => { + expect(adapter.id).toBe('storybook-mcp'); }); - it('has correct surface metadata classification', () => { + it('has correct displayName', () => { + expect(adapter.displayName).toBe('Storybook MCP Adapter'); + }); + + it('has version 0.1.0', () => { + expect(adapter.version).toBe('0.1.0'); + }); +}); + +// ============================================================================= +// SURFACE METADATA +// ============================================================================= + +describe('StorybookMCPAdapter surface metadata', () => { + const adapter = new StorybookMCPAdapter(makeConfig()); + + it('has correct surface classification', () => { expect(adapter.surfaceMetadata).toEqual({ surfaceType: 'runtime', accessMode: 'read-only', @@ -24,39 +74,159 @@ describe('StorybookAdapter', () => { stability: 'observational', }); }); +}); + +// ============================================================================= +// CAPABILITY MANIFEST +// ============================================================================= + +describe('StorybookMCPAdapter capabilities', () => { + it('reports correct capabilities in default (unavailable) state', () => { + const adapter = new StorybookMCPAdapter(makeConfig()); + const caps = adapter.getCapabilities(); - it('reports as unavailable (stub)', async () => { - expect(await adapter.isAvailable()).toBe(false); + // Storybook-specific: no tokens, no styles, no screenshots + expect(caps.readDesignTokens).toBe(false); + expect(caps.readComponents).toBe(true); + expect(caps.readStyles).toBe(false); + expect(caps.readFileData).toBe(true); + expect(caps.readScreenshots).toBe(false); + }); + + it('all write capabilities are literally false (non-negotiable)', () => { + const adapter = new StorybookMCPAdapter(makeConfig()); + const caps = adapter.getCapabilities(); + + expect(caps.writeDesign).toStrictEqual(false); + expect(caps.writeVariables).toStrictEqual(false); + expect(caps.executeDesignCode).toStrictEqual(false); + expect(caps.writeVariableCollections).toStrictEqual(false); + expect(caps.cloudWriteRelay).toStrictEqual(false); + expect(caps.writeFigJam).toStrictEqual(false); + expect(caps.writeSlides).toStrictEqual(false); + }); +}); + +// ============================================================================= +// TOOL REGISTRIES +// ============================================================================= + +describe('StorybookMCPAdapter tool registries', () => { + it('BLOCKED_STORYBOOK_TOOLS has entries with required fields', () => { + expect(BLOCKED_STORYBOOK_TOOLS.length).toBeGreaterThan(0); + for (const entry of BLOCKED_STORYBOOK_TOOLS) { + expect(entry.tool).toBeTruthy(); + expect(entry.reason).toBeTruthy(); + expect(entry.category).toBeTruthy(); + } }); - it('returns empty design tokens', async () => { + it('BLOCKED_STORYBOOK_TOOL_NAMES matches BLOCKED_STORYBOOK_TOOLS', () => { + expect(BLOCKED_STORYBOOK_TOOL_NAMES.size).toBe(BLOCKED_STORYBOOK_TOOLS.length); + for (const entry of BLOCKED_STORYBOOK_TOOLS) { + expect(BLOCKED_STORYBOOK_TOOL_NAMES.has(entry.tool)).toBe(true); + } + }); + + it('run-story-tests is blocked (has side-effects)', () => { + expect(BLOCKED_STORYBOOK_TOOL_NAMES.has('run-story-tests')).toBe(true); + }); + + it('preview-stories is blocked', () => { + expect(BLOCKED_STORYBOOK_TOOL_NAMES.has('preview-stories')).toBe(true); + }); + + it('ALLOWED_STORYBOOK_TOOLS contains only documentation/read tools', () => { + for (const tool of ALLOWED_STORYBOOK_TOOLS) { + expect(tool).toMatch(/^(list-|get-)/); + } + }); + + it('allowed and blocked tools do not overlap', () => { + for (const tool of ALLOWED_STORYBOOK_TOOLS) { + expect(BLOCKED_STORYBOOK_TOOL_NAMES.has(tool)).toBe(false); + } + }); + + it('list-all-documentation is allowed', () => { + expect(ALLOWED_STORYBOOK_TOOL_NAMES.has('list-all-documentation')).toBe(true); + }); + + it('get-documentation is allowed', () => { + expect(ALLOWED_STORYBOOK_TOOL_NAMES.has('get-documentation')).toBe(true); + }); +}); + +// ============================================================================= +// AVAILABILITY +// ============================================================================= + +describe('StorybookMCPAdapter availability', () => { + it('returns false when dev server is not running', async () => { + const adapter = new StorybookMCPAdapter(makeConfig({ + url: 'http://localhost:59999', + })); + const available = await adapter.isAvailable(); + expect(available).toBe(false); + expect(adapter.operatingMode).toBe('unavailable'); + }); + + it('provides actionable unavailableReason with configured URL', async () => { + const url = 'http://localhost:59999'; + const adapter = new StorybookMCPAdapter(makeConfig({ url })); + await adapter.isAvailable(); + expect(adapter.unavailableReason).toContain('not reachable'); + expect(adapter.unavailableReason).toContain(url); + }); + + it('isAvailable completes quickly when server is down (< 5s)', async () => { + const adapter = new StorybookMCPAdapter(makeConfig({ + url: 'http://localhost:59999', + })); + const start = Date.now(); + await adapter.isAvailable(); + const elapsed = Date.now() - start; + // Should complete within health check timeout (2s) + buffer + expect(elapsed).toBeLessThan(5000); + }); +}); + +// ============================================================================= +// ADAPTER RESULT SHAPE +// ============================================================================= + +describe('StorybookMCPAdapter result shape', () => { + const adapter = new StorybookMCPAdapter(makeConfig()); + + it('getDesignTokens returns empty (Storybook has no tokens)', async () => { const result = await adapter.getDesignTokens(); expect(result.data).toEqual([]); - expect(result.adapterId).toBe('storybook'); + expect(result.adapterId).toBe('storybook-mcp'); + expect(result.adapterName).toBe('Storybook MCP Adapter'); + expect(typeof result.durationMs).toBe('number'); + expect(Array.isArray(result.warnings)).toBe(true); + expect(typeof result.cached).toBe('boolean'); }); - it('returns mock component by name', async () => { - const result = await adapter.getComponent('Button'); - expect(result.data).not.toBeNull(); - expect(result.data!.name).toBe('Button'); - expect(result.data!.id).toBe('storybook:Button'); + it('getStyles returns empty (Storybook has no style defs)', async () => { + const result = await adapter.getStyles(); + expect(result.data).toEqual([]); + expect(result.adapterId).toBe('storybook-mcp'); }); - it('blocks all write capabilities', () => { - const caps = adapter.getCapabilities(); - expect(caps.writeDesign).toBe(false); - expect(caps.writeVariables).toBe(false); - expect(caps.executeDesignCode).toBe(false); - expect(caps.writeVariableCollections).toBe(false); - expect(caps.cloudWriteRelay).toBe(false); - expect(caps.writeFigJam).toBe(false); - expect(caps.writeSlides).toBe(false); + it('getScreenshot returns null (not supported in Phase 16C)', async () => { + const result = await adapter.getScreenshot(); + expect(result.data).toBeNull(); + expect(result.warnings).toContain('Screenshots not supported in Phase 16C'); }); - it('allows readComponents only', () => { - const caps = adapter.getCapabilities(); - expect(caps.readComponents).toBe(true); - expect(caps.readDesignTokens).toBe(false); - expect(caps.readStyles).toBe(false); + it('getFileData returns valid DesignFileData shape', async () => { + const result = await adapter.getFileData(); + expect(result.data.name).toBe('Storybook'); + expect(typeof result.data.lastModified).toBe('string'); + expect(typeof result.data.pageCount).toBe('number'); + expect(typeof result.data.componentCount).toBe('number'); + expect(typeof result.data.styleCount).toBe('number'); + expect(typeof result.data.variableCount).toBe('number'); }); }); diff --git a/packages/watcher/src/designAdapter/index.ts b/packages/watcher/src/designAdapter/index.ts index 80195f1..a321c0f 100644 --- a/packages/watcher/src/designAdapter/index.ts +++ b/packages/watcher/src/designAdapter/index.ts @@ -26,9 +26,15 @@ export { FigmaConsoleMCPAdapter } from './figmaConsoleMCPAdapter.js'; export type { FigmaConsoleMCPConfig, MCPTransportMode } from './figmaConsoleMCPAdapter.js'; export { BLOCKED_MCP_TOOLS, BLOCKED_TOOL_NAMES, ALLOWED_MCP_TOOLS, ALLOWED_TOOL_NAMES } from './figmaConsoleMCPAdapter.js'; -// Stub only — NOT auto-registered. Validates the Surface Classification Metadata model. -// Future Storybook integration would replace this with a real implementation. -export { StorybookAdapter } from './storybookAdapter.js'; +// Phase 16C: Storybook MCP Adapter — connects to @storybook/addon-mcp for +// read-only component metadata extraction and cross-surface drift analysis. +export { StorybookMCPAdapter } from './storybookAdapter.js'; +export { + BLOCKED_STORYBOOK_TOOLS, + BLOCKED_STORYBOOK_TOOL_NAMES, + ALLOWED_STORYBOOK_TOOLS, + ALLOWED_STORYBOOK_TOOL_NAMES, +} from './storybookAdapter.js'; export type { NormalizedToken, diff --git a/packages/watcher/src/designAdapter/storybookAdapter.ts b/packages/watcher/src/designAdapter/storybookAdapter.ts index 91c01c4..9d93323 100644 --- a/packages/watcher/src/designAdapter/storybookAdapter.ts +++ b/packages/watcher/src/designAdapter/storybookAdapter.ts @@ -1,51 +1,233 @@ /** * @aesthetic-function/watcher - designAdapter/storybookAdapter.ts * - * Phase 16A Extension: Storybook Adapter (Stub Only). + * Phase 16C: Storybook MCP Adapter (Read-Only). * - * WHY: This minimal stub validates the Surface Classification Metadata model - * by demonstrating how a runtime observation adapter (Storybook) would be - * classified and registered. It reads component states from Storybook—in this - * stub, via mock data—and returns structured props/variants. + * INTEGRATION: This adapter connects to @storybook/addon-mcp running inside + * the Storybook dev server. AF acts as an MCP client and invokes the addon's + * read-only documentation tools by name via the MCP protocol. * - * CRITICAL CONSTRAINTS: - * - READ-ONLY. No mutation capability. - * - No reconciliation logic. No runtime coupling. - * - External and non-authoritative — AF does not treat Storybook data as canonical. - * - This is a PLACEHOLDER. It is exported but NOT auto-registered. + * TRANSPORT: + * 1. MCP — AF connects to @storybook/addon-mcp at {storybookUrl}/mcp via + * StreamableHTTP transport. This gives access to all documentation tools. + * + * 2. HTTP FALLBACK — If MCP is unavailable but Storybook is running, AF falls + * back to direct HTTP requests to the manifest endpoints: + * - /manifests/components.json + * - /manifests/docs.json + * This provides component inventory and props but without MCP tool features. + * + * CRITICAL CONSTRAINTS (NON-NEGOTIABLE): + * - READ-ONLY. No write operations. No mutations. + * - Write tools are NEVER invoked, even if exposed. + * - Does not bypass watcher → server → AF plugin authority. + * - Does not own reconciliation, policy, or persistence. + * - If unavailable, AF works fully without it. + * + * SURFACE CLASSIFICATION: + * - surfaceType: "runtime" — Storybook is a rendered component catalog/runtime + * (code-adjacent documentation metadata, not raw code AST) + * - accessMode: "read-only" — AF only reads component state + * - authorityRole: "external-non-authoritative" — data is informational + * - stability: "observational" — point-in-time snapshot */ +import { Client } from '@modelcontextprotocol/sdk/client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse'; + import type { DesignAdapter, AdapterResult, AdapterCapabilityManifest, DesignTokenValue, DesignComponent, + DesignVariant, DesignStyle, DesignFileData, DesignScreenshot, } from '@aesthetic-function/shared/designAdapter'; import type { SurfaceMetadata } from '@aesthetic-function/shared/surfaceMetadata'; +import type { + StorybookMCPConfig, + StorybookOperatingMode, + StorybookComponentMeta, + StorybookProp, + StorybookStory, + StorybookInventory, +} from '@aesthetic-function/shared/storybookAdapter'; + +// ============================================================================= +// BLOCKED TOOL REGISTRY +// ============================================================================= + +/** + * Registry of @storybook/addon-mcp tools that are INTENTIONALLY BLOCKED in AF. + * + * These tools have mutation or execution side-effects. AF's mutation authority + * flows through watcher → server → AF plugin. Even read-adjacent tools that + * trigger test execution are blocked because they have side-effects. + */ +export const BLOCKED_STORYBOOK_TOOLS = [ + { + tool: 'run-story-tests', + reason: 'Test execution has side-effects (spawns vitest, modifies test state). AF adapters are observation-only.', + category: 'test-execution' as const, + }, + { + tool: 'preview-stories', + reason: 'Preview URL generation may trigger renders. AF reads manifest data only.', + category: 'preview' as const, + }, +] as const; + +export const BLOCKED_STORYBOOK_TOOL_NAMES = new Set( + BLOCKED_STORYBOOK_TOOLS.map(t => t.tool), +); + +// ============================================================================= +// ALLOWED @storybook/addon-mcp READ TOOLS +// ============================================================================= + +/** + * @storybook/addon-mcp + @storybook/mcp tools that AF is allowed to invoke. + * These are read-only documentation and metadata tools. + * + * Discovered from @storybook/addon-mcp v0.5.0 + @storybook/mcp v0.6.2 + * by inspecting packages/addon-mcp/src/tools/ and packages/mcp/src/tools/. + */ +export const ALLOWED_STORYBOOK_TOOLS = [ + 'list-all-documentation', // → getComponents(), getInventory() + 'get-documentation', // → getComponent(), getComponentMeta() + 'get-documentation-for-story', // → story-level detail + 'get-storybook-story-instructions', // → framework detection, story patterns +] as const; + +export const ALLOWED_STORYBOOK_TOOL_NAMES = new Set(ALLOWED_STORYBOOK_TOOLS); + +// ============================================================================= +// HEALTH CHECK TIMEOUT (fast, for isAvailable) +// ============================================================================= + +const HEALTH_CHECK_TIMEOUT_MS = 2000; + +// ============================================================================= +// MCP CLIENT MANAGEMENT +// ============================================================================= + +/** + * Invoke a Storybook MCP tool via MCP protocol and return the text result. + * + * SAFETY: Default-deny. Only tools in ALLOWED_STORYBOOK_TOOL_NAMES may be invoked. + */ +async function callStorybookMCPTool( + client: Client, + toolName: string, + args: Record, +): Promise { + // Guard 1: reject known blocked tools with explicit reason + const blockedEntry = BLOCKED_STORYBOOK_TOOLS.find(t => t.tool === toolName); + if (blockedEntry) { + throw new Error( + `BLOCKED: Tool "${toolName}" is blocked by AF architecture. ` + + `Reason: ${blockedEntry.reason}`, + ); + } + + // Guard 2: default-deny — only explicitly allowed tools may be invoked + if (!ALLOWED_STORYBOOK_TOOL_NAMES.has(toolName as typeof ALLOWED_STORYBOOK_TOOLS[number])) { + throw new Error( + `DENIED: Tool "${toolName}" is not in ALLOWED_STORYBOOK_TOOLS. ` + + `AF uses a default-deny policy — only explicitly allowed read tools may be invoked.`, + ); + } + + const result = await client.callTool({ name: toolName, arguments: args }); + + // Extract text content from MCP result + const textParts = (result.content as Array<{ type: string; text?: string }>) + .filter(c => c.type === 'text' && c.text) + .map(c => c.text!); + + return textParts.join('\n'); +} + +// ============================================================================= +// STORYBOOK MANIFEST TYPES (from @storybook/mcp component manifest) +// ============================================================================= + +interface StorybookManifestComponent { + id: string; + name: string; + path: string; + description?: string; + summary?: string; + import?: string; + stories?: StorybookManifestStory[]; + reactDocgen?: ReactDocgenData; + reactDocgenTypescript?: ReactDocgenTypescriptData; + reactComponentMeta?: ReactDocgenTypescriptData; + docs?: Record; +} + +interface StorybookManifestStory { + name: string; + id?: string; + description?: string; + snippet?: string; + summary?: string; +} + +interface ReactDocgenData { + props?: Record; +} + +interface ReactDocgenTypescriptData { + props?: Record; +} + +interface TsType { + name: string; + raw?: string; + value?: string; + elements?: TsType[]; +} + +interface StorybookManifestMap { + v: number; + components: Record; +} // ============================================================================= -// STORYBOOK ADAPTER (STUB) +// STORYBOOK MCP ADAPTER // ============================================================================= /** - * Storybook Adapter — stub implementation for model validation. + * Storybook MCP Adapter — connects to @storybook/addon-mcp for read-only + * component metadata extraction. * * Surface classification: - * - surfaceType: "runtime" — Storybook is a component runtime/preview + * - surfaceType: "runtime" — Storybook is a rendered component catalog * - accessMode: "read-only" — AF only reads component state - * - authorityRole: "external-non-authoritative" — Storybook data is informational + * - authorityRole: "external-non-authoritative" — informational only * - stability: "observational" — point-in-time component snapshot */ -export class StorybookAdapter implements DesignAdapter { - readonly id = 'storybook'; - readonly displayName = 'Storybook Adapter'; +export class StorybookMCPAdapter implements DesignAdapter { + readonly id = 'storybook-mcp'; + readonly displayName = 'Storybook MCP Adapter'; readonly version = '0.1.0'; - /** Surface classification: runtime, read-only, non-authoritative, observational */ readonly surfaceMetadata: SurfaceMetadata = { surfaceType: 'runtime', accessMode: 'read-only', @@ -53,59 +235,214 @@ export class StorybookAdapter implements DesignAdapter { stability: 'observational', }; - async isAvailable(): Promise { - // Stub: always unavailable unless Storybook integration is configured - return false; + private config: StorybookMCPConfig; + private mcpClient: Client | null = null; + private mcpConnectionAttempted = false; + private _operatingMode: StorybookOperatingMode = 'unavailable'; + private _unavailableReason: string | null = null; + + get operatingMode(): StorybookOperatingMode { + return this._operatingMode; } - async getDesignTokens(): Promise> { - return this.emptyResult([]); + get unavailableReason(): string | null { + return this._unavailableReason; } - async getComponent(name: string): Promise> { - // Stub: return mock component data for validation - const component: DesignComponent = { - name, - id: `storybook:${name}`, - type: 'component', - properties: {}, - variants: [], - }; - return this.emptyResult(component); + constructor(config: StorybookMCPConfig) { + this.config = config; } - async getComponents(): Promise> { - return this.emptyResult([]); + // --------------------------------------------------------------------------- + // AVAILABILITY & CONNECTION + // --------------------------------------------------------------------------- + + async isAvailable(): Promise { + // Step 1: Health check the Storybook dev server (fast, 2s timeout) + const reachable = await this.probeDevServer(); + if (!reachable) { + this._operatingMode = 'unavailable'; + this._unavailableReason = + `Storybook dev server not reachable at ${this.config.url}`; + return false; + } + + // Step 2: Validate framework is React + const framework = await this.detectFramework(); + const expectedFramework = this.config.framework ?? 'react'; + if (framework && !framework.includes(expectedFramework)) { + this._operatingMode = 'unavailable'; + this._unavailableReason = + `Storybook framework "${framework}" is not supported. ` + + `Phase 16C requires React. See: @storybook/addon-mcp compatibility.`; + return false; + } + + // Step 3: Try MCP endpoint + const mcpOk = await this.probeMCPEndpoint(); + if (mcpOk) { + this._operatingMode = 'mcp'; + this._unavailableReason = null; + return true; + } + + // Step 4: MCP unavailable but server is up → degraded HTTP fallback + this._operatingMode = 'http-fallback'; + this._unavailableReason = null; + return true; } - async getStyles(): Promise> { - return this.emptyResult([]); + /** + * Fast probe of the Storybook dev server (HEAD request, 2s timeout). + */ + private async probeDevServer(): Promise { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); + try { + const response = await fetch(this.config.url, { + method: 'HEAD', + signal: controller.signal, + }); + return response.ok || response.status === 405; // Some servers don't support HEAD + } finally { + clearTimeout(timer); + } + } catch { + return false; + } } - async getFileData(): Promise> { - return this.emptyResult({ - name: 'Storybook', - lastModified: new Date().toISOString(), - pageCount: 0, - componentCount: 0, - styleCount: 0, - variableCount: 0, - }); + /** + * Probe the MCP endpoint to check if addon-mcp is installed. + */ + private async probeMCPEndpoint(): Promise { + try { + const mcpUrl = `${this.config.url}${this.config.mcpPath ?? '/mcp'}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); + try { + const response = await fetch(mcpUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'aesthetic-function-probe', version: '0.1.0' }, + } }), + signal: controller.signal, + }); + // MCP endpoint should respond with 200 and JSON-RPC + return response.ok; + } finally { + clearTimeout(timer); + } + } catch { + return false; + } } - async getScreenshot(): Promise> { - return this.emptyResult(null); + /** + * Detect the Storybook framework from the manifest or runtime info. + * Returns null if detection fails (we proceed with a warning in that case). + */ + private async detectFramework(): Promise { + try { + // Try to detect framework from Storybook's runtime headers or index + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); + try { + const response = await fetch(`${this.config.url}/index.json`, { + signal: controller.signal, + }); + if (response.ok) { + // Storybook index.json doesn't directly expose framework, but we can + // check the manifest for reactDocgen presence as a React indicator. + // For now, return null (unknown) if we can't detect. + return null; + } + } finally { + clearTimeout(timer); + } + } catch { + // Detection failed — not a hard error, proceed with warning + } + return null; } + /** + * Lazy-connect to the MCP endpoint. + */ + private async ensureMCPConnection(): Promise { + if (this.mcpClient) return this.mcpClient; + if (this.mcpConnectionAttempted) return null; + this.mcpConnectionAttempted = true; + + if (this._operatingMode === 'http-fallback' || this._operatingMode === 'unavailable') { + return null; + } + + try { + const mcpUrl = `${this.config.url}${this.config.mcpPath ?? '/mcp'}`; + + // Try StreamableHTTP first (modern MCP transport) + try { + const transport = new StreamableHTTPClientTransport( + new URL(mcpUrl), + ); + const client = new Client( + { name: 'aesthetic-function', version: '0.1.0' }, + { capabilities: {} }, + ); + await client.connect(transport); + this.mcpClient = client; + this._operatingMode = 'mcp'; + return client; + } catch { + // StreamableHTTP failed, try SSE + } + + // Try SSE transport (older MCP servers) + try { + const transport = new SSEClientTransport( + new URL(mcpUrl), + ); + const client = new Client( + { name: 'aesthetic-function', version: '0.1.0' }, + { capabilities: {} }, + ); + await client.connect(transport); + this.mcpClient = client; + this._operatingMode = 'mcp'; + return client; + } catch { + // SSE also failed — fall back to HTTP + } + + this._operatingMode = 'http-fallback'; + return null; + } catch { + this._operatingMode = 'http-fallback'; + return null; + } + } + + // --------------------------------------------------------------------------- + // CAPABILITY MANIFEST (reflects actual operating mode) + // --------------------------------------------------------------------------- + getCapabilities(): AdapterCapabilityManifest { + const isMCP = this._operatingMode === 'mcp'; + return { - readDesignTokens: false, - readComponents: true, - readStyles: false, - readFileData: false, - readScreenshots: false, - readDesignSystemKit: false, - readDesignCodeParity: false, + // Available capabilities (read-only) + readDesignTokens: false, // Storybook doesn't have design tokens + readComponents: true, // Available in both MCP and HTTP fallback + readStyles: false, // Storybook doesn't have style definitions + readFileData: true, // Basic file data from manifest + readScreenshots: false, // Not supported in Phase 16C + readDesignSystemKit: isMCP, // Only via MCP tools + readDesignCodeParity: false, // Not applicable to Storybook // BLOCKED by AF architecture (non-negotiable) writeDesign: false, @@ -118,17 +455,562 @@ export class StorybookAdapter implements DesignAdapter { }; } + // --------------------------------------------------------------------------- + // DESIGN ADAPTER INTERFACE METHODS + // --------------------------------------------------------------------------- + + async getDesignTokens(): Promise> { + // Storybook does not provide design tokens — return empty + return this.makeResult([], ['Storybook does not provide design tokens']); + } + + async getComponent(name: string): Promise> { + const start = Date.now(); + const warnings: string[] = []; + + try { + const client = await this.ensureMCPConnection(); + + if (client) { + // MCP path: use get-documentation tool + const raw = await callStorybookMCPTool(client, 'get-documentation', { id: name.toLowerCase() }); + warnings.push(`transport: mcp`); + const meta = this.parseDocumentationResponse(raw, name); + if (!meta) return this.makeResult(null, [...warnings, `Component "${name}" not found`], start); + return this.makeResult(this.metaToDesignComponent(meta), warnings, start); + } + + // HTTP fallback: read manifest directly + warnings.push('transport: http-fallback'); + const manifest = await this.fetchComponentManifest(); + if (!manifest) return this.makeResult(null, [...warnings, 'Component manifest unavailable'], start); + + const component = this.findComponentInManifest(manifest, name); + if (!component) return this.makeResult(null, [...warnings, `Component "${name}" not found`], start); + + const meta = this.manifestComponentToMeta(component); + return this.makeResult(this.metaToDesignComponent(meta), warnings, start); + } catch (error) { + warnings.push(`Error: ${(error as Error).message}`); + return this.makeResult(null, warnings, start); + } + } + + async getComponents(): Promise> { + const start = Date.now(); + const warnings: string[] = []; + + try { + const client = await this.ensureMCPConnection(); + + if (client) { + // MCP path: list-all-documentation with story IDs + const raw = await callStorybookMCPTool(client, 'list-all-documentation', { withStoryIds: true }); + warnings.push('transport: mcp'); + // The list response is markdown — parse component names and fetch details + const componentNames = this.parseListResponse(raw); + const components: DesignComponent[] = []; + for (const componentName of componentNames) { + try { + const docRaw = await callStorybookMCPTool(client, 'get-documentation', { id: componentName }); + const meta = this.parseDocumentationResponse(docRaw, componentName); + if (meta) components.push(this.metaToDesignComponent(meta)); + } catch { + warnings.push(`Failed to fetch details for "${componentName}"`); + } + } + return this.makeResult(components, warnings, start); + } + + // HTTP fallback: read manifest directly + warnings.push('transport: http-fallback'); + const manifest = await this.fetchComponentManifest(); + if (!manifest) return this.makeResult([], [...warnings, 'Component manifest unavailable'], start); + + const components = Object.values(manifest.components).map(c => + this.metaToDesignComponent(this.manifestComponentToMeta(c)), + ); + return this.makeResult(components, warnings, start); + } catch (error) { + warnings.push(`Error: ${(error as Error).message}`); + return this.makeResult([], warnings, start); + } + } + + async getStyles(): Promise> { + // Storybook does not provide style definitions — return empty + return this.makeResult([], ['Storybook does not provide style definitions']); + } + + async getFileData(): Promise> { + const start = Date.now(); + const warnings: string[] = []; + + try { + const manifest = await this.fetchComponentManifest(); + warnings.push(`transport: ${this._operatingMode === 'mcp' ? 'mcp' : 'http-fallback'}`); + + const componentCount = manifest ? Object.keys(manifest.components).length : 0; + const storyCount = manifest + ? Object.values(manifest.components).reduce((sum, c) => sum + (c.stories?.length ?? 0), 0) + : 0; + + return this.makeResult({ + name: 'Storybook', + lastModified: new Date().toISOString(), + pageCount: 0, + componentCount, + styleCount: 0, + variableCount: 0, + meta: { + storyCount, + manifestVersion: manifest?.v, + operatingMode: this._operatingMode, + }, + }, warnings, start); + } catch (error) { + warnings.push(`Error: ${(error as Error).message}`); + return this.makeResult({ + name: 'Storybook', + lastModified: new Date().toISOString(), + pageCount: 0, + componentCount: 0, + styleCount: 0, + variableCount: 0, + }, warnings, start); + } + } + + async getScreenshot(): Promise> { + return this.makeResult(null, ['Screenshots not supported in Phase 16C']); + } + + // --------------------------------------------------------------------------- + // STORYBOOK-SPECIFIC METHODS + // --------------------------------------------------------------------------- + + /** + * Get rich component metadata including props, stories, and variant axes. + * This goes beyond the DesignAdapter interface to provide Storybook-specific data. + */ + async getComponentMeta(name: string): Promise> { + const start = Date.now(); + const warnings: string[] = []; + + try { + const client = await this.ensureMCPConnection(); + + if (client) { + const raw = await callStorybookMCPTool(client, 'get-documentation', { id: name.toLowerCase() }); + warnings.push('transport: mcp'); + const meta = this.parseDocumentationResponse(raw, name); + return this.makeResult(meta, warnings, start); + } + + // HTTP fallback + warnings.push('transport: http-fallback'); + const manifest = await this.fetchComponentManifest(); + if (!manifest) return this.makeResult(null, [...warnings, 'Manifest unavailable'], start); + + const component = this.findComponentInManifest(manifest, name); + if (!component) return this.makeResult(null, [...warnings, `Component "${name}" not found`], start); + + return this.makeResult(this.manifestComponentToMeta(component), warnings, start); + } catch (error) { + warnings.push(`Error: ${(error as Error).message}`); + return this.makeResult(null, warnings, start); + } + } + + /** + * Get the full inventory of all components and stories in Storybook. + */ + async getInventory(): Promise> { + const start = Date.now(); + const warnings: string[] = []; + + try { + const manifest = await this.fetchComponentManifest(); + warnings.push(`transport: ${this._operatingMode === 'mcp' ? 'mcp' : 'http-fallback'}`); + + if (!manifest) { + return this.makeResult({ + components: [], + totalStories: 0, + manifestAvailable: false, + }, [...warnings, 'Component manifest unavailable'], start); + } + + const components = Object.values(manifest.components).map(c => + this.manifestComponentToMeta(c), + ); + const totalStories = components.reduce((sum, c) => sum + c.stories.length, 0); + + return this.makeResult({ + components, + totalStories, + manifestAvailable: true, + }, warnings, start); + } catch (error) { + warnings.push(`Error: ${(error as Error).message}`); + return this.makeResult({ + components: [], + totalStories: 0, + manifestAvailable: false, + }, warnings, start); + } + } + + // --------------------------------------------------------------------------- + // MANIFEST FETCHING (HTTP) + // --------------------------------------------------------------------------- + + private async fetchComponentManifest(): Promise { + try { + const url = `${this.config.url}/manifests/components.json`; + const timeout = this.config.timeout ?? 30000; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { 'Accept': 'application/json' }, + signal: controller.signal, + }); + + if (!response.ok) { + return null; + } + + return await response.json() as StorybookManifestMap; + } finally { + clearTimeout(timer); + } + } catch { + return null; + } + } + + // --------------------------------------------------------------------------- + // DATA CONVERSION + // --------------------------------------------------------------------------- + + /** + * Convert a raw manifest component to StorybookComponentMeta. + */ + private manifestComponentToMeta(component: StorybookManifestComponent): StorybookComponentMeta { + const props = this.extractProps(component); + const stories = (component.stories ?? []).map(s => this.manifestStoryToStory(s, props)); + + return { + name: component.name, + id: component.id, + importPath: component.import, + description: component.description ?? component.summary, + props, + stories, + }; + } + + /** + * Extract props from reactDocgen, reactDocgenTypescript, or reactComponentMeta. + */ + private extractProps(component: StorybookManifestComponent): StorybookProp[] { + // Try reactDocgen first (most detailed) + if (component.reactDocgen?.props) { + return Object.entries(component.reactDocgen.props).map(([name, prop]) => ({ + name, + type: this.serializeTsType(prop.tsType ?? prop.type) ?? 'any', + defaultValue: prop.defaultValue?.value, + required: prop.required ?? false, + description: prop.description, + })); + } + + // Try reactDocgenTypescript + if (component.reactDocgenTypescript?.props) { + return Object.entries(component.reactDocgenTypescript.props).map(([name, prop]) => ({ + name, + type: prop.type?.raw ?? prop.type?.name ?? 'any', + defaultValue: prop.defaultValue?.value, + required: prop.required ?? false, + description: prop.description, + })); + } + + // Try reactComponentMeta + if (component.reactComponentMeta?.props) { + return Object.entries(component.reactComponentMeta.props).map(([name, prop]) => ({ + name, + type: prop.type?.raw ?? prop.type?.name ?? 'any', + defaultValue: prop.defaultValue?.value, + required: prop.required ?? false, + description: prop.description, + })); + } + + return []; + } + + /** + * Serialize a TsType object to a type string. + * Mirrors @storybook/mcp's serializeTsType logic. + */ + private serializeTsType(tsType: TsType | undefined): string | null { + if (!tsType) return null; + if (tsType.raw) return tsType.raw; + if (tsType.value) return tsType.value; + if (tsType.elements) { + const inner = tsType.elements + .map(el => this.serializeTsType(el) ?? 'unknown') + .filter(Boolean); + if (inner.length > 0) return `${tsType.name}<${inner.join(', ')}>`; + } + return tsType.name; + } + + /** + * Convert a manifest story to StorybookStory with variant axis inference. + */ + private manifestStoryToStory( + story: StorybookManifestStory, + props: StorybookProp[], + ): StorybookStory { + const result: StorybookStory = { + id: story.id ?? '', + name: story.name, + snippet: story.snippet, + }; + + // Infer variant axes from story name matching prop values + const variantAxes = this.inferVariantAxes(story.name, props); + if (Object.keys(variantAxes).length > 0) { + result.variantAxes = variantAxes; + } + + return result; + } + + /** + * Infer variant axes by matching story name against prop names and union values. + * + * e.g., Story "Primary" + prop { variant: "'primary' | 'secondary'" } + * → { variant: 'primary' } + */ + private inferVariantAxes( + storyName: string, + props: StorybookProp[], + ): Record { + const axes: Record = {}; + const normalizedName = storyName.toLowerCase().replace(/\s+/g, ''); + + for (const prop of props) { + // Extract union values from type string like "'primary' | 'secondary' | 'ghost'" + const unionValues = this.extractUnionValues(prop.type); + if (unionValues.length === 0) continue; + + for (const value of unionValues) { + if (value.toLowerCase().replace(/\s+/g, '') === normalizedName) { + axes[prop.name] = value; + break; + } + } + } + + return axes; + } + + /** + * Extract string literal values from a union type string. + * "'primary' | 'secondary' | 'ghost'" → ['primary', 'secondary', 'ghost'] + */ + private extractUnionValues(typeStr: string): string[] { + const matches = typeStr.match(/'([^']+)'/g); + if (!matches) return []; + return matches.map(m => m.replace(/'/g, '')); + } + + /** + * Convert StorybookComponentMeta to DesignComponent for the DesignAdapter interface. + */ + private metaToDesignComponent(meta: StorybookComponentMeta): DesignComponent { + const variants: DesignVariant[] = []; + + // Convert stories with variant axes to DesignVariant + for (const story of meta.stories) { + if (story.variantAxes && Object.keys(story.variantAxes).length > 0) { + variants.push({ + name: story.name, + id: story.id, + properties: story.variantAxes, + }); + } + } + + return { + name: meta.name, + id: `storybook:${meta.id}`, + type: 'component', + properties: { + props: meta.props.map((p: StorybookProp) => ({ + name: p.name, + type: p.type, + required: p.required, + defaultValue: p.defaultValue, + })), + storyCount: meta.stories.length, + importPath: meta.importPath, + }, + variants: variants.length > 0 ? variants : undefined, + }; + } + + /** + * Find a component in the manifest by name (case-insensitive). + */ + private findComponentInManifest( + manifest: StorybookManifestMap, + name: string, + ): StorybookManifestComponent | null { + const lower = name.toLowerCase(); + + // Try exact ID match first + if (manifest.components[lower]) { + return manifest.components[lower]; + } + + // Try name match (case-insensitive) + for (const component of Object.values(manifest.components)) { + if (component.name.toLowerCase() === lower) { + return component; + } + } + + return null; + } + + // --------------------------------------------------------------------------- + // MCP RESPONSE PARSING + // --------------------------------------------------------------------------- + + /** + * Parse the markdown response from get-documentation tool. + * Returns null if the component wasn't found. + */ + private parseDocumentationResponse( + raw: string, + name: string, + ): StorybookComponentMeta | null { + if (raw.includes('not found') || raw.includes('isError')) { + return null; + } + + // Parse the markdown response from @storybook/mcp + const meta: StorybookComponentMeta = { + name: '', + id: name.toLowerCase(), + props: [], + stories: [], + }; + + // Extract component name from "# ComponentName" + const nameMatch = raw.match(/^#\s+(.+)$/m); + if (nameMatch) { + meta.name = nameMatch[1].trim(); + } else { + meta.name = name; + } + + // Extract ID from "ID: componentid" + const idMatch = raw.match(/^ID:\s+(.+)$/m); + if (idMatch) { + meta.id = idMatch[1].trim(); + } + + // Extract description (text between name/ID and first ## heading) + const descMatch = raw.match(/^ID:\s+.+\n\n([\s\S]*?)(?=^##|\n```)/m); + if (descMatch && descMatch[1].trim()) { + meta.description = descMatch[1].trim(); + } + + // Extract stories from "## Stories" section + const storiesMatch = raw.match(/## Stories\n([\s\S]*?)(?=## Props|$)/); + if (storiesMatch) { + const storiesBlock = storiesMatch[1]; + const storyHeaders = storiesBlock.matchAll(/### (.+)\n(?:\nStory ID:\s+(.+)\n)?/g); + for (const match of storyHeaders) { + const story: StorybookStory = { + name: match[1].trim(), + id: match[2]?.trim() ?? '', + }; + // Look for code snippet after this story header + const snippetMatch = storiesBlock.match( + new RegExp(`### ${match[1].trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?\`\`\`\\n([\\s\\S]*?)\`\`\``, 'm'), + ); + if (snippetMatch) { + story.snippet = snippetMatch[1].trim(); + } + meta.stories.push(story); + } + } + + // Extract props from "## Props" section + const propsMatch = raw.match(/## Props\n\n```\n([\s\S]*?)```/); + if (propsMatch) { + const propsBlock = propsMatch[1]; + // Parse TypeScript-like prop definitions + const propLines = propsBlock.matchAll( + /(?:\/\*\*\s*\n\s*(.+?)\s*\n\s*\*\/\n\s*)?(\w+)(\?)?:\s*(.+?)(?:\s*=\s*(.+?))?$/gm, + ); + for (const match of propLines) { + if (match[2] === 'export' || match[2] === 'type') continue; + meta.props.push({ + name: match[2], + type: match[4]?.replace(/;$/, '').trim() ?? 'any', + required: !match[3], + defaultValue: match[5]?.trim(), + description: match[1]?.trim(), + }); + } + } + + // Infer variant axes for stories based on props + for (const story of meta.stories) { + const axes = this.inferVariantAxes(story.name, meta.props); + if (Object.keys(axes).length > 0) { + story.variantAxes = axes; + } + } + + return meta; + } + + /** + * Parse the list response from list-all-documentation to extract component names. + */ + private parseListResponse(raw: string): string[] { + const names: string[] = []; + // The list response contains markdown with component names + // Format: "- componentid: description" or "## Components\n- componentid" + const matches = raw.matchAll(/^[-*]\s+(\w[\w-]*)/gm); + for (const match of matches) { + names.push(match[1]); + } + return names; + } + // --------------------------------------------------------------------------- // HELPERS // --------------------------------------------------------------------------- - private emptyResult(data: T): AdapterResult { + private makeResult(data: T, warnings: string[] = [], startMs?: number): AdapterResult { return { data, adapterId: this.id, adapterName: this.displayName, - durationMs: 0, - warnings: ['Storybook adapter is a stub — no real data'], + durationMs: startMs ? Date.now() - startMs : 0, + warnings, cached: false, }; }