Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
358 lines
12 KiB
Python
358 lines
12 KiB
Python
"""
|
|
Storybook Scanner
|
|
|
|
Discovers and analyzes existing Storybook stories in a project.
|
|
"""
|
|
|
|
import re
|
|
import json
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Optional, Set
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
@dataclass
|
|
class StoryInfo:
|
|
"""Information about a Storybook story."""
|
|
name: str # Story name (e.g., "Primary")
|
|
title: str # Story title (e.g., "Components/Button")
|
|
component: str # Component name
|
|
file_path: str # Path to story file
|
|
args: Dict[str, Any] = field(default_factory=dict) # Default args
|
|
parameters: Dict[str, Any] = field(default_factory=dict)
|
|
decorators: List[str] = field(default_factory=list)
|
|
tags: List[str] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"name": self.name,
|
|
"title": self.title,
|
|
"component": self.component,
|
|
"file_path": self.file_path,
|
|
"args": self.args,
|
|
"parameters": self.parameters,
|
|
"decorators": self.decorators,
|
|
"tags": self.tags,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class StorybookConfig:
|
|
"""Storybook configuration details."""
|
|
version: str = ""
|
|
framework: str = "" # react, vue, angular, etc.
|
|
builder: str = "" # vite, webpack5, etc.
|
|
addons: List[str] = field(default_factory=list)
|
|
stories_patterns: List[str] = field(default_factory=list)
|
|
static_dirs: List[str] = field(default_factory=list)
|
|
config_path: str = ""
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"version": self.version,
|
|
"framework": self.framework,
|
|
"builder": self.builder,
|
|
"addons": self.addons,
|
|
"stories_patterns": self.stories_patterns,
|
|
"static_dirs": self.static_dirs,
|
|
"config_path": self.config_path,
|
|
}
|
|
|
|
|
|
class StorybookScanner:
|
|
"""
|
|
Scans a project for Storybook configuration and stories.
|
|
"""
|
|
|
|
# Common story file patterns
|
|
STORY_PATTERNS = [
|
|
'*.stories.tsx',
|
|
'*.stories.ts',
|
|
'*.stories.jsx',
|
|
'*.stories.js',
|
|
'*.stories.mdx',
|
|
]
|
|
|
|
def __init__(self, root_path: str):
|
|
self.root = Path(root_path).resolve()
|
|
|
|
async def scan(self) -> Dict[str, Any]:
|
|
"""
|
|
Perform full Storybook scan.
|
|
|
|
Returns:
|
|
Dict with configuration and story inventory
|
|
"""
|
|
config = await self._find_config()
|
|
stories = await self._find_stories()
|
|
|
|
# Group stories by component
|
|
by_component: Dict[str, List[StoryInfo]] = {}
|
|
for story in stories:
|
|
if story.component not in by_component:
|
|
by_component[story.component] = []
|
|
by_component[story.component].append(story)
|
|
|
|
return {
|
|
"config": config.to_dict() if config else None,
|
|
"stories_count": len(stories),
|
|
"components_with_stories": len(by_component),
|
|
"stories": [s.to_dict() for s in stories],
|
|
"by_component": {
|
|
comp: [s.to_dict() for s in stories_list]
|
|
for comp, stories_list in by_component.items()
|
|
},
|
|
}
|
|
|
|
async def _find_config(self) -> Optional[StorybookConfig]:
|
|
"""Find and parse Storybook configuration."""
|
|
# Look for .storybook directory
|
|
storybook_dir = self.root / ".storybook"
|
|
if not storybook_dir.exists():
|
|
# Try alternative locations
|
|
for alt in ["storybook", ".storybook"]:
|
|
alt_path = self.root / alt
|
|
if alt_path.exists():
|
|
storybook_dir = alt_path
|
|
break
|
|
else:
|
|
return None
|
|
|
|
config = StorybookConfig(config_path=str(storybook_dir))
|
|
|
|
# Parse main.js/ts
|
|
for main_file in ["main.ts", "main.js", "main.mjs"]:
|
|
main_path = storybook_dir / main_file
|
|
if main_path.exists():
|
|
await self._parse_main_config(main_path, config)
|
|
break
|
|
|
|
# Check package.json for Storybook version
|
|
pkg_json = self.root / "package.json"
|
|
if pkg_json.exists():
|
|
try:
|
|
pkg = json.loads(pkg_json.read_text())
|
|
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
|
|
|
# Get Storybook version
|
|
for pkg_name in ["@storybook/react", "@storybook/vue3", "@storybook/angular"]:
|
|
if pkg_name in deps:
|
|
config.version = deps[pkg_name].lstrip("^~")
|
|
config.framework = pkg_name.split("/")[1]
|
|
break
|
|
|
|
# Get builder
|
|
if "@storybook/builder-vite" in deps:
|
|
config.builder = "vite"
|
|
elif "@storybook/builder-webpack5" in deps:
|
|
config.builder = "webpack5"
|
|
|
|
# Get addons
|
|
config.addons = [
|
|
pkg for pkg in deps.keys()
|
|
if pkg.startswith("@storybook/addon-")
|
|
]
|
|
|
|
except (json.JSONDecodeError, KeyError):
|
|
pass
|
|
|
|
return config
|
|
|
|
async def _parse_main_config(self, main_path: Path, config: StorybookConfig) -> None:
|
|
"""Parse main.js/ts for configuration."""
|
|
try:
|
|
content = main_path.read_text(encoding="utf-8")
|
|
|
|
# Extract stories patterns
|
|
stories_match = re.search(
|
|
r'stories\s*:\s*\[([^\]]+)\]',
|
|
content,
|
|
re.DOTALL
|
|
)
|
|
if stories_match:
|
|
patterns_str = stories_match.group(1)
|
|
patterns = re.findall(r'["\']([^"\']+)["\']', patterns_str)
|
|
config.stories_patterns = patterns
|
|
|
|
# Extract static dirs
|
|
static_match = re.search(
|
|
r'staticDirs\s*:\s*\[([^\]]+)\]',
|
|
content,
|
|
re.DOTALL
|
|
)
|
|
if static_match:
|
|
dirs_str = static_match.group(1)
|
|
dirs = re.findall(r'["\']([^"\']+)["\']', dirs_str)
|
|
config.static_dirs = dirs
|
|
|
|
# Extract framework
|
|
framework_match = re.search(
|
|
r'framework\s*:\s*["\'](@storybook/[^"\']+)["\']',
|
|
content
|
|
)
|
|
if framework_match:
|
|
config.framework = framework_match.group(1)
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
async def _find_stories(self) -> List[StoryInfo]:
|
|
"""Find all story files in the project."""
|
|
stories = []
|
|
skip_dirs = {'node_modules', '.git', 'dist', 'build'}
|
|
|
|
for pattern in self.STORY_PATTERNS:
|
|
for story_path in self.root.rglob(pattern):
|
|
if any(skip in story_path.parts for skip in skip_dirs):
|
|
continue
|
|
|
|
try:
|
|
file_stories = await self._parse_story_file(story_path)
|
|
stories.extend(file_stories)
|
|
except Exception:
|
|
continue
|
|
|
|
return stories
|
|
|
|
async def _parse_story_file(self, story_path: Path) -> List[StoryInfo]:
|
|
"""Parse a story file to extract story information."""
|
|
content = story_path.read_text(encoding="utf-8", errors="ignore")
|
|
rel_path = str(story_path.relative_to(self.root))
|
|
stories = []
|
|
|
|
# Extract meta/default export
|
|
title = ""
|
|
component = ""
|
|
|
|
# CSF3 format: const meta = { title: '...', component: ... }
|
|
meta_match = re.search(
|
|
r'(?:const\s+meta|export\s+default)\s*[=:]\s*\{([^}]+)\}',
|
|
content,
|
|
re.DOTALL
|
|
)
|
|
if meta_match:
|
|
meta_content = meta_match.group(1)
|
|
|
|
title_match = re.search(r'title\s*:\s*["\']([^"\']+)["\']', meta_content)
|
|
if title_match:
|
|
title = title_match.group(1)
|
|
|
|
comp_match = re.search(r'component\s*:\s*(\w+)', meta_content)
|
|
if comp_match:
|
|
component = comp_match.group(1)
|
|
|
|
# If no title, derive from file path
|
|
if not title:
|
|
# Convert path to title (e.g., src/components/Button.stories.tsx -> Components/Button)
|
|
parts = story_path.stem.replace('.stories', '').split('/')
|
|
title = '/'.join(p.title() for p in parts[-2:] if p)
|
|
|
|
if not component:
|
|
component = story_path.stem.replace('.stories', '')
|
|
|
|
# Find exported stories (CSF3 format)
|
|
# export const Primary: Story = { ... }
|
|
story_pattern = re.compile(
|
|
r'export\s+const\s+(\w+)\s*(?::\s*\w+)?\s*=\s*\{([^}]*)\}',
|
|
re.DOTALL
|
|
)
|
|
|
|
for match in story_pattern.finditer(content):
|
|
story_name = match.group(1)
|
|
story_content = match.group(2)
|
|
|
|
# Skip meta export
|
|
if story_name.lower() in ['meta', 'default']:
|
|
continue
|
|
|
|
# Parse args
|
|
args = {}
|
|
args_match = re.search(r'args\s*:\s*\{([^}]*)\}', story_content)
|
|
if args_match:
|
|
args_str = args_match.group(1)
|
|
# Simple key-value extraction
|
|
for kv_match in re.finditer(r'(\w+)\s*:\s*["\']?([^,\n"\']+)["\']?', args_str):
|
|
args[kv_match.group(1)] = kv_match.group(2).strip()
|
|
|
|
stories.append(StoryInfo(
|
|
name=story_name,
|
|
title=title,
|
|
component=component,
|
|
file_path=rel_path,
|
|
args=args,
|
|
))
|
|
|
|
# Also check for older CSF2 format
|
|
# export const Primary = Template.bind({})
|
|
csf2_pattern = re.compile(
|
|
r'export\s+const\s+(\w+)\s*=\s*Template\.bind\(\{\}\)'
|
|
)
|
|
for match in csf2_pattern.finditer(content):
|
|
story_name = match.group(1)
|
|
if not any(s.name == story_name for s in stories):
|
|
stories.append(StoryInfo(
|
|
name=story_name,
|
|
title=title,
|
|
component=component,
|
|
file_path=rel_path,
|
|
))
|
|
|
|
return stories
|
|
|
|
async def get_components_without_stories(
|
|
self,
|
|
component_files: List[str]
|
|
) -> List[str]:
|
|
"""
|
|
Find components that don't have Storybook stories.
|
|
|
|
Args:
|
|
component_files: List of component file paths
|
|
|
|
Returns:
|
|
List of component paths without stories
|
|
"""
|
|
# Get all components with stories
|
|
result = await self.scan()
|
|
components_with_stories = set(result.get("by_component", {}).keys())
|
|
|
|
# Find components without stories
|
|
without_stories = []
|
|
for comp_path in component_files:
|
|
# Extract component name from path
|
|
comp_name = Path(comp_path).stem
|
|
if comp_name not in components_with_stories:
|
|
without_stories.append(comp_path)
|
|
|
|
return without_stories
|
|
|
|
async def get_story_coverage(self) -> Dict[str, Any]:
|
|
"""
|
|
Calculate story coverage statistics.
|
|
|
|
Returns:
|
|
Coverage statistics including counts and percentages
|
|
"""
|
|
result = await self.scan()
|
|
|
|
stories_count = result.get("stories_count", 0)
|
|
components_count = result.get("components_with_stories", 0)
|
|
|
|
# Count stories per component
|
|
by_component = result.get("by_component", {})
|
|
stories_per_component = {
|
|
comp: len(stories) for comp, stories in by_component.items()
|
|
}
|
|
|
|
avg_stories = (
|
|
sum(stories_per_component.values()) / len(stories_per_component)
|
|
if stories_per_component else 0
|
|
)
|
|
|
|
return {
|
|
"total_stories": stories_count,
|
|
"components_covered": components_count,
|
|
"average_stories_per_component": round(avg_stories, 1),
|
|
"stories_per_component": stories_per_component,
|
|
}
|