Initial commit: Clean DSS implementation
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
This commit is contained in:
357
demo/tools/storybook/scanner.py
Normal file
357
demo/tools/storybook/scanner.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user