Files
dss/dss/analyze/quick_wins.py
2025-12-11 07:13:06 -03:00

443 lines
17 KiB
Python

"""
Quick-Win Finder.
Identifies easy improvement opportunities in a codebase:
- Inline styles that can be extracted
- Duplicate values that should be tokens
- Unused styles
- Naming inconsistencies
- Accessibility issues
"""
import re
from pathlib import Path
from typing import Any, Dict, List
from .base import Location, QuickWin, QuickWinPriority, QuickWinType
from .react import ReactAnalyzer
from .styles import StyleAnalyzer
class QuickWinFinder:
"""
Finds quick improvement opportunities in a project.
Categories:
- INLINE_STYLE: Inline styles that can be extracted to CSS/tokens
- DUPLICATE_VALUE: Repeated values that should be tokens
- UNUSED_STYLE: CSS that's defined but not used
- HARDCODED_VALUE: Magic numbers/colors that should be tokens
- NAMING_INCONSISTENCY: Inconsistent naming patterns
- DEPRECATED_PATTERN: Outdated styling approaches
- ACCESSIBILITY: A11y improvements
"""
def __init__(self, root_path: str):
self.root = Path(root_path).resolve()
self.style_analyzer = StyleAnalyzer(root_path)
self.react_analyzer = ReactAnalyzer(root_path)
async def find_all(self) -> List[QuickWin]:
"""
Find all quick-win opportunities.
Returns:
List of QuickWin objects sorted by priority
"""
quick_wins = []
# Find inline styles
inline_wins = await self._find_inline_style_wins()
quick_wins.extend(inline_wins)
# Find duplicate values
duplicate_wins = await self._find_duplicate_value_wins()
quick_wins.extend(duplicate_wins)
# Find unused styles
unused_wins = await self._find_unused_style_wins()
quick_wins.extend(unused_wins)
# Find hardcoded values
hardcoded_wins = await self._find_hardcoded_value_wins()
quick_wins.extend(hardcoded_wins)
# Find naming inconsistencies
naming_wins = await self._find_naming_inconsistency_wins()
quick_wins.extend(naming_wins)
# Find accessibility issues
a11y_wins = await self._find_accessibility_wins()
quick_wins.extend(a11y_wins)
# Sort by priority
priority_order = {
QuickWinPriority.CRITICAL: 0,
QuickWinPriority.HIGH: 1,
QuickWinPriority.MEDIUM: 2,
QuickWinPriority.LOW: 3,
}
quick_wins.sort(key=lambda x: priority_order[x.priority])
return quick_wins
async def _find_inline_style_wins(self) -> List[QuickWin]:
"""Find inline styles that should be extracted."""
wins = []
inline_styles = await self.react_analyzer.find_inline_styles()
if not inline_styles:
return wins
# Group by file
by_file = {}
for style in inline_styles:
file_path = style["file"]
if file_path not in by_file:
by_file[file_path] = []
by_file[file_path].append(style)
# Create quick-wins for files with multiple inline styles
for file_path, styles in by_file.items():
if len(styles) >= 3: # Only flag if 3+ inline styles
wins.append(
QuickWin(
type=QuickWinType.INLINE_STYLE,
priority=QuickWinPriority.HIGH,
title=f"Extract {len(styles)} inline styles",
description=f"File {file_path} has {len(styles)} inline style declarations that could be extracted to CSS classes or design tokens.",
location=Location(file_path, styles[0]["line"]),
affected_files=[file_path],
estimated_impact="Reduce inline styles, improve maintainability",
fix_suggestion="Extract repeated style properties to CSS classes or design tokens. Use className instead of style prop.",
auto_fixable=True,
)
)
# Create summary if many files have inline styles
total_inline = len(inline_styles)
if total_inline >= 10:
wins.insert(
0,
QuickWin(
type=QuickWinType.INLINE_STYLE,
priority=QuickWinPriority.HIGH,
title=f"Project has {total_inline} inline styles",
description=f"Found {total_inline} inline style declarations across {len(by_file)} files. Consider migrating to CSS classes or design tokens.",
affected_files=list(by_file.keys())[:10],
estimated_impact="Improve code maintainability and bundle size",
fix_suggestion="Run 'dss migrate inline-styles' to preview migration options.",
auto_fixable=True,
),
)
return wins
async def _find_duplicate_value_wins(self) -> List[QuickWin]:
"""Find duplicate values that should be tokens."""
wins = []
analysis = await self.style_analyzer.analyze()
duplicates = analysis.get("duplicates", [])
# Find high-occurrence duplicates
for dup in duplicates[:10]: # Top 10 duplicates
if dup["count"] >= 5: # Only if used 5+ times
priority = QuickWinPriority.HIGH if dup["count"] >= 10 else QuickWinPriority.MEDIUM
wins.append(
QuickWin(
type=QuickWinType.DUPLICATE_VALUE,
priority=priority,
title=f"Duplicate value '{dup['value']}' used {dup['count']} times",
description=f"The value '{dup['value']}' appears {dup['count']} times across {len(dup['files'])} files. This should be a design token.",
affected_files=dup["files"],
estimated_impact="Create single source of truth, easier theme updates",
fix_suggestion="Create token for this value and replace all occurrences.",
auto_fixable=True,
)
)
return wins
async def _find_unused_style_wins(self) -> List[QuickWin]:
"""Find unused CSS styles."""
wins = []
unused = await self.style_analyzer.find_unused_styles()
if len(unused) >= 5:
wins.append(
QuickWin(
type=QuickWinType.UNUSED_STYLE,
priority=QuickWinPriority.MEDIUM,
title=f"Found {len(unused)} potentially unused CSS classes",
description="These CSS classes are defined but don't appear to be used in the codebase. Review and remove if confirmed unused.",
affected_files=list(set(u["file"] for u in unused))[:10],
estimated_impact="Reduce CSS bundle size by removing dead code",
fix_suggestion="Review each class and remove if unused. Some may be dynamically generated.",
auto_fixable=False, # Needs human review
)
)
return wins
async def _find_hardcoded_value_wins(self) -> List[QuickWin]:
"""Find hardcoded magic values."""
wins = []
analysis = await self.style_analyzer.analyze()
candidates = analysis.get("token_candidates", [])
# Find high-confidence candidates
high_confidence = [c for c in candidates if c.confidence >= 0.7]
if high_confidence:
wins.append(
QuickWin(
type=QuickWinType.HARDCODED_VALUE,
priority=QuickWinPriority.MEDIUM,
title=f"Found {len(high_confidence)} values that should be tokens",
description="These hardcoded values appear multiple times and should be extracted as design tokens for consistency.",
estimated_impact="Improve theme consistency and make updates easier",
fix_suggestion="Use 'dss extract-tokens' to create tokens from these values.",
auto_fixable=True,
)
)
# Add specific wins for top candidates
for candidate in high_confidence[:5]:
wins.append(
QuickWin(
type=QuickWinType.HARDCODED_VALUE,
priority=QuickWinPriority.LOW,
title=f"Extract '{candidate.value}' as token",
description=f"Value '{candidate.value}' appears {candidate.occurrences} times. Suggested token: {candidate.suggested_name}",
location=candidate.locations[0] if candidate.locations else None,
affected_files=[loc.file_path for loc in candidate.locations[:5]],
estimated_impact="Single source of truth for this value",
fix_suggestion=f"Create token '{candidate.suggested_name}' with value '{candidate.value}'",
auto_fixable=True,
)
)
return wins
async def _find_naming_inconsistency_wins(self) -> List[QuickWin]:
"""Find naming inconsistencies."""
wins = []
naming = await self.style_analyzer.analyze_naming_consistency()
if naming.get("inconsistencies"):
primary = naming.get("primary_pattern", "unknown")
inconsistent_count = len(naming["inconsistencies"])
wins.append(
QuickWin(
type=QuickWinType.NAMING_INCONSISTENCY,
priority=QuickWinPriority.LOW,
title=f"Found {inconsistent_count} naming inconsistencies",
description=f"The project primarily uses {primary} naming, but {inconsistent_count} classes use different conventions.",
affected_files=list(set(i["file"] for i in naming["inconsistencies"]))[:10],
estimated_impact="Improve code consistency and readability",
fix_suggestion=f"Standardize all class names to use {primary} convention.",
auto_fixable=True,
)
)
return wins
async def _find_accessibility_wins(self) -> List[QuickWin]:
"""Find accessibility issues."""
wins = []
skip_dirs = {"node_modules", ".git", "dist", "build"}
a11y_issues = []
for ext in ["*.jsx", "*.tsx"]:
for file_path in self.root.rglob(ext):
if any(skip in file_path.parts for skip in skip_dirs):
continue
try:
content = file_path.read_text(encoding="utf-8", errors="ignore")
rel_path = str(file_path.relative_to(self.root))
# Check for images without alt
img_no_alt = re.findall(r'<img[^>]+(?<!alt=")[^>]*>', content)
if img_no_alt:
for match in img_no_alt[:3]:
if "alt=" not in match:
line = content[: content.find(match)].count("\n") + 1
a11y_issues.append(
{
"type": "img-no-alt",
"file": rel_path,
"line": line,
}
)
# Check for buttons without accessible text
icon_only_buttons = re.findall(
r"<button[^>]*>\s*<(?:svg|Icon|img)[^>]*/?>\s*</button>",
content,
re.IGNORECASE,
)
if icon_only_buttons:
a11y_issues.append(
{
"type": "icon-button-no-label",
"file": rel_path,
}
)
# Check for click handlers on non-interactive elements
div_onclick = re.findall(r"<div[^>]+onClick", content)
if div_onclick:
a11y_issues.append(
{
"type": "div-click-handler",
"file": rel_path,
"count": len(div_onclick),
}
)
except Exception:
continue
# Group issues by type
if a11y_issues:
img_issues = [i for i in a11y_issues if i["type"] == "img-no-alt"]
if img_issues:
wins.append(
QuickWin(
type=QuickWinType.ACCESSIBILITY,
priority=QuickWinPriority.HIGH,
title=f"Found {len(img_issues)} images without alt text",
description="Images should have alt attributes for screen readers. Empty alt='' is acceptable for decorative images.",
affected_files=list(set(i["file"] for i in img_issues))[:10],
estimated_impact="Improve accessibility for screen reader users",
fix_suggestion="Add descriptive alt text to images or alt='' for decorative images.",
auto_fixable=False,
)
)
div_issues = [i for i in a11y_issues if i["type"] == "div-click-handler"]
if div_issues:
wins.append(
QuickWin(
type=QuickWinType.ACCESSIBILITY,
priority=QuickWinPriority.MEDIUM,
title="Found click handlers on div elements",
description="Using onClick on div elements makes them inaccessible to keyboard users. Use button or add proper ARIA attributes.",
affected_files=list(set(i["file"] for i in div_issues))[:10],
estimated_impact="Improve keyboard navigation accessibility",
fix_suggestion="Replace <div onClick> with <button> or add role='button' and tabIndex={0}.",
auto_fixable=True,
)
)
return wins
async def get_summary(self) -> Dict[str, Any]:
"""Get summary of all quick-wins."""
wins = await self.find_all()
by_type = {}
by_priority = {}
for win in wins:
type_key = win.type.value
priority_key = win.priority.value
if type_key not in by_type:
by_type[type_key] = 0
by_type[type_key] += 1
if priority_key not in by_priority:
by_priority[priority_key] = 0
by_priority[priority_key] += 1
return {
"total": len(wins),
"by_type": by_type,
"by_priority": by_priority,
"auto_fixable": len([w for w in wins if w.auto_fixable]),
"top_wins": [w.to_dict() for w in wins[:10]],
}
async def get_actionable_report(self) -> str:
"""Generate human-readable report of quick-wins."""
wins = await self.find_all()
if not wins:
return "No quick-wins found. Your codebase looks clean!"
lines = [
"QUICK-WIN OPPORTUNITIES",
"=" * 50,
"",
]
# Group by priority
by_priority = {
QuickWinPriority.CRITICAL: [],
QuickWinPriority.HIGH: [],
QuickWinPriority.MEDIUM: [],
QuickWinPriority.LOW: [],
}
for win in wins:
by_priority[win.priority].append(win)
# Report by priority
priority_labels = {
QuickWinPriority.CRITICAL: "CRITICAL",
QuickWinPriority.HIGH: "HIGH PRIORITY",
QuickWinPriority.MEDIUM: "MEDIUM PRIORITY",
QuickWinPriority.LOW: "LOW PRIORITY",
}
for priority, label in priority_labels.items():
priority_wins = by_priority[priority]
if not priority_wins:
continue
lines.extend(
[
f"\n[{label}] ({len(priority_wins)} items)",
"-" * 40,
]
)
for i, win in enumerate(priority_wins[:5], 1):
lines.extend(
[
f"\n{i}. {win.title}",
f" {win.description[:100]}...",
f" Impact: {win.estimated_impact}",
]
)
if win.auto_fixable:
lines.append(" [Auto-fixable]")
if len(priority_wins) > 5:
lines.append(f"\n ... and {len(priority_wins) - 5} more")
# Summary
lines.extend(
[
"",
"=" * 50,
"SUMMARY",
f"Total quick-wins: {len(wins)}",
f"Auto-fixable: {len([w for w in wins if w.auto_fixable])}",
"",
"Run 'dss fix --preview' to see suggested changes.",
]
)
return "\n".join(lines)