Files
dss/demo/tools/analyze/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

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)