Files
dss/demo/tools/storybook/scanner.py
Digital Production Factory 276ed71f31 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
2025-12-09 18:45:48 -03:00

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,
}