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
503 lines
17 KiB
Python
503 lines
17 KiB
Python
"""
|
|
Project Scanner
|
|
|
|
Scans file system to discover project structure, frameworks, and style files.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Optional, Set, Tuple
|
|
from dataclasses import dataclass, field
|
|
|
|
from .base import (
|
|
Framework,
|
|
StylingApproach,
|
|
StyleFile,
|
|
ProjectAnalysis,
|
|
)
|
|
|
|
|
|
# Directories to skip during scanning
|
|
SKIP_DIRS = {
|
|
'node_modules',
|
|
'.git',
|
|
'.next',
|
|
'.nuxt',
|
|
'dist',
|
|
'build',
|
|
'out',
|
|
'.cache',
|
|
'coverage',
|
|
'__pycache__',
|
|
'.venv',
|
|
'venv',
|
|
'.turbo',
|
|
'.vercel',
|
|
}
|
|
|
|
# File extensions to scan
|
|
SCAN_EXTENSIONS = {
|
|
# JavaScript/TypeScript
|
|
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
|
|
# Styles
|
|
'.css', '.scss', '.sass', '.less', '.styl',
|
|
# Config
|
|
'.json',
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class ScanResult:
|
|
"""Result of file system scan."""
|
|
files: List[Path] = field(default_factory=list)
|
|
style_files: List[Path] = field(default_factory=list)
|
|
component_files: List[Path] = field(default_factory=list)
|
|
config_files: Dict[str, Path] = field(default_factory=dict)
|
|
total_lines: int = 0
|
|
|
|
|
|
class ProjectScanner:
|
|
"""
|
|
Scans a project directory to identify:
|
|
- Framework (React, Next, Vue, etc.)
|
|
- Styling approach (CSS modules, styled-components, Tailwind, etc.)
|
|
- Component files
|
|
- Style files
|
|
|
|
Results are cached in memory for the session.
|
|
"""
|
|
|
|
# Class-level cache: path -> (timestamp, analysis)
|
|
_cache: Dict[str, Tuple[float, ProjectAnalysis]] = {}
|
|
_cache_ttl: float = 60.0 # Cache for 60 seconds
|
|
|
|
def __init__(self, root_path: str, use_cache: bool = True):
|
|
self.root = Path(root_path).resolve()
|
|
self.use_cache = use_cache
|
|
if not self.root.exists():
|
|
raise FileNotFoundError(f"Project path not found: {root_path}")
|
|
|
|
async def scan(self) -> ProjectAnalysis:
|
|
"""
|
|
Perform full project scan.
|
|
|
|
Returns:
|
|
ProjectAnalysis with detected framework, styles, and files
|
|
"""
|
|
# Check cache if enabled
|
|
if self.use_cache:
|
|
import time
|
|
cache_key = str(self.root)
|
|
if cache_key in self._cache:
|
|
timestamp, cached_analysis = self._cache[cache_key]
|
|
if time.time() - timestamp < self._cache_ttl:
|
|
return cached_analysis
|
|
|
|
# Scan file system
|
|
scan_result = self._scan_files()
|
|
|
|
# Detect framework
|
|
framework, version = self._detect_framework(scan_result.config_files)
|
|
|
|
# Detect styling approaches
|
|
styling = self._detect_styling(scan_result)
|
|
|
|
# Collect style files
|
|
style_files = self._analyze_style_files(scan_result.style_files)
|
|
|
|
# Build analysis result
|
|
analysis = ProjectAnalysis(
|
|
project_path=str(self.root),
|
|
framework=framework,
|
|
framework_version=version,
|
|
style_files=style_files,
|
|
style_file_count=len(style_files),
|
|
stats={
|
|
"total_files_scanned": len(scan_result.files),
|
|
"total_lines": scan_result.total_lines,
|
|
"component_files": len(scan_result.component_files),
|
|
"style_files": len(scan_result.style_files),
|
|
}
|
|
)
|
|
|
|
# Determine primary styling approach
|
|
if styling:
|
|
analysis.styling_approaches = styling
|
|
# Primary is the one with most occurrences
|
|
analysis.primary_styling = max(
|
|
styling, key=lambda x: x.count
|
|
).type if styling else None
|
|
|
|
# Cache result if enabled
|
|
if self.use_cache:
|
|
import time
|
|
cache_key = str(self.root)
|
|
self._cache[cache_key] = (time.time(), analysis)
|
|
|
|
return analysis
|
|
|
|
def _scan_files(self) -> ScanResult:
|
|
"""Scan directory for relevant files."""
|
|
result = ScanResult()
|
|
|
|
for path in self.root.rglob("*"):
|
|
# Skip directories in skip list
|
|
if any(skip in path.parts for skip in SKIP_DIRS):
|
|
continue
|
|
|
|
if not path.is_file():
|
|
continue
|
|
|
|
suffix = path.suffix.lower()
|
|
if suffix not in SCAN_EXTENSIONS:
|
|
continue
|
|
|
|
result.files.append(path)
|
|
|
|
# Categorize files
|
|
if suffix in {'.css', '.scss', '.sass', '.less', '.styl'}:
|
|
result.style_files.append(path)
|
|
elif suffix in {'.jsx', '.tsx'}:
|
|
result.component_files.append(path)
|
|
elif suffix in {'.js', '.ts'}:
|
|
# Check if it's a component or config
|
|
name = path.name.lower()
|
|
if any(cfg in name for cfg in ['config', 'rc', '.config']):
|
|
result.config_files[name] = path
|
|
elif self._looks_like_component(path):
|
|
result.component_files.append(path)
|
|
|
|
# Count lines (approximate for large files)
|
|
try:
|
|
content = path.read_text(encoding='utf-8', errors='ignore')
|
|
result.total_lines += content.count('\n') + 1
|
|
except Exception:
|
|
pass
|
|
|
|
# Look for specific config files
|
|
config_names = [
|
|
'package.json',
|
|
'tsconfig.json',
|
|
'tailwind.config.js',
|
|
'tailwind.config.ts',
|
|
'next.config.js',
|
|
'next.config.mjs',
|
|
'vite.config.js',
|
|
'vite.config.ts',
|
|
'nuxt.config.js',
|
|
'nuxt.config.ts',
|
|
'.eslintrc.json',
|
|
'.eslintrc.js',
|
|
]
|
|
|
|
for name in config_names:
|
|
config_path = self.root / name
|
|
if config_path.exists():
|
|
result.config_files[name] = config_path
|
|
|
|
return result
|
|
|
|
def _looks_like_component(self, path: Path) -> bool:
|
|
"""Check if a JS/TS file looks like a React component."""
|
|
name = path.stem
|
|
# PascalCase is a strong indicator
|
|
if name[0].isupper() and not name.isupper():
|
|
return True
|
|
# Common component patterns
|
|
if any(x in name.lower() for x in ['component', 'page', 'view', 'screen']):
|
|
return True
|
|
return False
|
|
|
|
def _detect_framework(
|
|
self,
|
|
config_files: Dict[str, Path]
|
|
) -> Tuple[Framework, str]:
|
|
"""Detect the UI framework and version."""
|
|
# Check package.json for dependencies
|
|
pkg_json = config_files.get('package.json')
|
|
if not pkg_json:
|
|
return Framework.UNKNOWN, ""
|
|
|
|
try:
|
|
pkg = json.loads(pkg_json.read_text())
|
|
deps = {
|
|
**pkg.get('dependencies', {}),
|
|
**pkg.get('devDependencies', {}),
|
|
}
|
|
|
|
# Check for Next.js first (it includes React)
|
|
if 'next' in deps:
|
|
return Framework.NEXT, deps.get('next', '').lstrip('^~')
|
|
|
|
# Check for Nuxt (Vue-based)
|
|
if 'nuxt' in deps:
|
|
return Framework.NUXT, deps.get('nuxt', '').lstrip('^~')
|
|
|
|
# Check for other frameworks
|
|
if 'react' in deps:
|
|
return Framework.REACT, deps.get('react', '').lstrip('^~')
|
|
|
|
if 'vue' in deps:
|
|
return Framework.VUE, deps.get('vue', '').lstrip('^~')
|
|
|
|
if '@angular/core' in deps:
|
|
return Framework.ANGULAR, deps.get('@angular/core', '').lstrip('^~')
|
|
|
|
if 'svelte' in deps:
|
|
return Framework.SVELTE, deps.get('svelte', '').lstrip('^~')
|
|
|
|
if 'solid-js' in deps:
|
|
return Framework.SOLID, deps.get('solid-js', '').lstrip('^~')
|
|
|
|
except (json.JSONDecodeError, KeyError):
|
|
pass
|
|
|
|
return Framework.UNKNOWN, ""
|
|
|
|
def _detect_styling(self, scan_result: ScanResult) -> List:
|
|
"""Detect styling approaches used in the project."""
|
|
from .base import StylePattern, Location
|
|
|
|
patterns: Dict[StylingApproach, StylePattern] = {}
|
|
|
|
# Check config files for styling indicators
|
|
pkg_json = scan_result.config_files.get('package.json')
|
|
if pkg_json:
|
|
try:
|
|
pkg = json.loads(pkg_json.read_text())
|
|
deps = {
|
|
**pkg.get('dependencies', {}),
|
|
**pkg.get('devDependencies', {}),
|
|
}
|
|
|
|
# Tailwind
|
|
if 'tailwindcss' in deps:
|
|
patterns[StylingApproach.TAILWIND] = StylePattern(
|
|
type=StylingApproach.TAILWIND,
|
|
count=1,
|
|
examples=["tailwindcss in dependencies"]
|
|
)
|
|
|
|
# styled-components
|
|
if 'styled-components' in deps:
|
|
patterns[StylingApproach.STYLED_COMPONENTS] = StylePattern(
|
|
type=StylingApproach.STYLED_COMPONENTS,
|
|
count=1,
|
|
examples=["styled-components in dependencies"]
|
|
)
|
|
|
|
# Emotion
|
|
if '@emotion/react' in deps or '@emotion/styled' in deps:
|
|
patterns[StylingApproach.EMOTION] = StylePattern(
|
|
type=StylingApproach.EMOTION,
|
|
count=1,
|
|
examples=["@emotion in dependencies"]
|
|
)
|
|
|
|
# SASS/SCSS
|
|
if 'sass' in deps or 'node-sass' in deps:
|
|
patterns[StylingApproach.SASS_SCSS] = StylePattern(
|
|
type=StylingApproach.SASS_SCSS,
|
|
count=1,
|
|
examples=["sass in dependencies"]
|
|
)
|
|
|
|
except (json.JSONDecodeError, KeyError):
|
|
pass
|
|
|
|
# Check tailwind config
|
|
if 'tailwind.config.js' in scan_result.config_files or \
|
|
'tailwind.config.ts' in scan_result.config_files:
|
|
if StylingApproach.TAILWIND not in patterns:
|
|
patterns[StylingApproach.TAILWIND] = StylePattern(
|
|
type=StylingApproach.TAILWIND,
|
|
count=1,
|
|
examples=["tailwind.config found"]
|
|
)
|
|
|
|
# Scan component files for styling patterns
|
|
for comp_file in scan_result.component_files[:100]: # Limit for performance
|
|
try:
|
|
content = comp_file.read_text(encoding='utf-8', errors='ignore')
|
|
self._detect_patterns_in_file(
|
|
content, str(comp_file), patterns
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Check style files
|
|
for style_file in scan_result.style_files:
|
|
suffix = style_file.suffix.lower()
|
|
|
|
if suffix == '.css':
|
|
# Check for CSS modules
|
|
if '.module.css' in style_file.name.lower():
|
|
approach = StylingApproach.CSS_MODULES
|
|
else:
|
|
approach = StylingApproach.VANILLA_CSS
|
|
|
|
if approach not in patterns:
|
|
patterns[approach] = StylePattern(type=approach)
|
|
patterns[approach].count += 1
|
|
patterns[approach].locations.append(
|
|
Location(str(style_file), 1)
|
|
)
|
|
|
|
elif suffix in {'.scss', '.sass'}:
|
|
if StylingApproach.SASS_SCSS not in patterns:
|
|
patterns[StylingApproach.SASS_SCSS] = StylePattern(
|
|
type=StylingApproach.SASS_SCSS
|
|
)
|
|
patterns[StylingApproach.SASS_SCSS].count += 1
|
|
|
|
return list(patterns.values())
|
|
|
|
def _detect_patterns_in_file(
|
|
self,
|
|
content: str,
|
|
file_path: str,
|
|
patterns: Dict[StylingApproach, Any]
|
|
) -> None:
|
|
"""Detect styling patterns in a single file."""
|
|
from .base import StylePattern, Location
|
|
|
|
# CSS Modules import
|
|
css_module_pattern = re.compile(
|
|
r"import\s+\w+\s+from\s+['\"].*\.module\.(css|scss|sass)['\"]"
|
|
)
|
|
for match in css_module_pattern.finditer(content):
|
|
if StylingApproach.CSS_MODULES not in patterns:
|
|
patterns[StylingApproach.CSS_MODULES] = StylePattern(
|
|
type=StylingApproach.CSS_MODULES
|
|
)
|
|
patterns[StylingApproach.CSS_MODULES].count += 1
|
|
line_num = content[:match.start()].count('\n') + 1
|
|
patterns[StylingApproach.CSS_MODULES].locations.append(
|
|
Location(file_path, line_num)
|
|
)
|
|
|
|
# styled-components
|
|
styled_pattern = re.compile(
|
|
r"(styled\.|styled\()|(from\s+['\"]styled-components['\"])"
|
|
)
|
|
for match in styled_pattern.finditer(content):
|
|
if StylingApproach.STYLED_COMPONENTS not in patterns:
|
|
patterns[StylingApproach.STYLED_COMPONENTS] = StylePattern(
|
|
type=StylingApproach.STYLED_COMPONENTS
|
|
)
|
|
patterns[StylingApproach.STYLED_COMPONENTS].count += 1
|
|
|
|
# Emotion
|
|
emotion_pattern = re.compile(
|
|
r"(css`|@emotion|from\s+['\"]@emotion)"
|
|
)
|
|
for match in emotion_pattern.finditer(content):
|
|
if StylingApproach.EMOTION not in patterns:
|
|
patterns[StylingApproach.EMOTION] = StylePattern(
|
|
type=StylingApproach.EMOTION
|
|
)
|
|
patterns[StylingApproach.EMOTION].count += 1
|
|
|
|
# Inline styles
|
|
inline_pattern = re.compile(
|
|
r'style\s*=\s*\{\s*\{[^}]+\}\s*\}'
|
|
)
|
|
for match in inline_pattern.finditer(content):
|
|
if StylingApproach.INLINE_STYLES not in patterns:
|
|
patterns[StylingApproach.INLINE_STYLES] = StylePattern(
|
|
type=StylingApproach.INLINE_STYLES
|
|
)
|
|
patterns[StylingApproach.INLINE_STYLES].count += 1
|
|
line_num = content[:match.start()].count('\n') + 1
|
|
patterns[StylingApproach.INLINE_STYLES].locations.append(
|
|
Location(file_path, line_num)
|
|
)
|
|
patterns[StylingApproach.INLINE_STYLES].examples.append(
|
|
match.group(0)[:100]
|
|
)
|
|
|
|
# Tailwind classes
|
|
tailwind_pattern = re.compile(
|
|
r'className\s*=\s*["\'][^"\']*(?:flex|grid|p-|m-|bg-|text-|border-)[^"\']*["\']'
|
|
)
|
|
for match in tailwind_pattern.finditer(content):
|
|
if StylingApproach.TAILWIND not in patterns:
|
|
patterns[StylingApproach.TAILWIND] = StylePattern(
|
|
type=StylingApproach.TAILWIND
|
|
)
|
|
patterns[StylingApproach.TAILWIND].count += 1
|
|
|
|
def _analyze_style_files(self, style_paths: List[Path]) -> List[StyleFile]:
|
|
"""Analyze style files for metadata."""
|
|
style_files = []
|
|
|
|
for path in style_paths:
|
|
try:
|
|
content = path.read_text(encoding='utf-8', errors='ignore')
|
|
|
|
# Determine type
|
|
suffix = path.suffix.lower()
|
|
if '.module.' in path.name.lower():
|
|
file_type = 'css-module'
|
|
elif suffix == '.scss':
|
|
file_type = 'scss'
|
|
elif suffix == '.sass':
|
|
file_type = 'sass'
|
|
elif suffix == '.less':
|
|
file_type = 'less'
|
|
else:
|
|
file_type = 'css'
|
|
|
|
# Count variables
|
|
var_count = 0
|
|
if file_type == 'css' or file_type == 'css-module':
|
|
var_count = len(re.findall(r'--[\w-]+\s*:', content))
|
|
elif file_type in {'scss', 'sass'}:
|
|
var_count = len(re.findall(r'\$[\w-]+\s*:', content))
|
|
|
|
# Count selectors (approximate)
|
|
selector_count = len(re.findall(r'[.#][\w-]+\s*\{', content))
|
|
|
|
# Find imports
|
|
imports = re.findall(r'@import\s+["\']([^"\']+)["\']', content)
|
|
|
|
style_files.append(StyleFile(
|
|
path=str(path.relative_to(self.root)),
|
|
type=file_type,
|
|
size_bytes=path.stat().st_size,
|
|
line_count=content.count('\n') + 1,
|
|
variable_count=var_count,
|
|
selector_count=selector_count,
|
|
imports=imports,
|
|
))
|
|
|
|
except Exception:
|
|
continue
|
|
|
|
return style_files
|
|
|
|
def get_file_tree(self, max_depth: int = 3) -> Dict[str, Any]:
|
|
"""Get project file tree structure."""
|
|
def build_tree(path: Path, depth: int) -> Dict[str, Any]:
|
|
if depth > max_depth:
|
|
return {"...": "truncated"}
|
|
|
|
result = {}
|
|
try:
|
|
for item in sorted(path.iterdir()):
|
|
if item.name in SKIP_DIRS:
|
|
continue
|
|
|
|
if item.is_dir():
|
|
result[item.name + "/"] = build_tree(item, depth + 1)
|
|
elif item.suffix in SCAN_EXTENSIONS:
|
|
result[item.name] = item.stat().st_size
|
|
|
|
except PermissionError:
|
|
pass
|
|
|
|
return result
|
|
|
|
return build_tree(self.root, 0)
|