From 9dbd56271e7f48c95bbf8ccc9a023a73a39f2012 Mon Sep 17 00:00:00 2001 From: DSS Date: Thu, 11 Dec 2025 09:41:36 -0300 Subject: [PATCH] feat: Enterprise DSS architecture implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete implementation of enterprise design system validation: Phase 1 - @dss/rules npm package: - CLI with validate and init commands - 16 rules across 5 categories (colors, spacing, typography, components, a11y) - dss-ignore support (inline and next-line) - Break-glass [dss-skip] for emergency merges - CI workflow templates (Gitea, GitHub, GitLab) Phase 2 - Metrics dashboard: - FastAPI metrics API with SQLite storage - Portfolio-wide metrics aggregation - Project drill-down with file:line:column violations - Trend charts and history tracking Phase 3 - Local analysis cache: - LocalAnalysisCache for offline-capable validation - Mode detection (LOCAL/REMOTE/CI) - Stale cache warnings with recommendations Phase 4 - Project onboarding: - dss-init command for project setup - Creates ds.config.json, .dss/ folder structure - Updates .gitignore and package.json scripts - Optional CI workflow setup Architecture decisions: - No commit-back: CI uploads to dashboard, not git - Three-tier: Dashboard (read-only) → CI (authoritative) → Local (advisory) - Pull-based rules via npm for version control šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/components/PortfolioDashboard.tsx | 424 +++++++++++ admin-ui/src/styles/portfolio.css | 360 ++++++++++ admin-ui/src/workdesks/UIWorkdesk.tsx | 3 + apps/api/metrics.py | 663 ++++++++++++++++++ apps/api/server.py | 4 + dss-claude-plugin/core/__init__.py | 13 + dss-claude-plugin/core/config.py | 149 +++- dss-claude-plugin/core/context.py | 120 +++- dss-claude-plugin/core/local_cache.py | 402 +++++++++++ .../hooks/.state/.git-backup.lock | 2 +- packages/dss-rules/bin/cli.js | 361 ++++++++++ packages/dss-rules/bin/init.js | 489 +++++++++++++ packages/dss-rules/lib/index.js | 373 +++++++--- packages/dss-rules/package.json | 18 +- packages/dss-rules/rules/accessibility.json | 76 +- packages/dss-rules/rules/colors.json | 27 +- packages/dss-rules/rules/components.json | 62 +- packages/dss-rules/rules/spacing.json | 30 +- packages/dss-rules/rules/typography.json | 47 +- .../dss-rules/schemas/ds.config.schema.json | 118 ++++ packages/dss-rules/schemas/rule.schema.json | 112 +-- packages/dss-rules/templates/ds.config.json | 23 + .../dss-rules/templates/dss-folder/.gitkeep | 1 + .../dss-rules/templates/gitea-workflow.yml | 122 ++++ .../dss-rules/templates/github-workflow.yml | 152 ++++ .../templates/gitignore-additions.txt | 9 + packages/dss-rules/templates/gitlab-ci.yml | 126 ++++ 27 files changed, 3888 insertions(+), 398 deletions(-) create mode 100644 admin-ui/src/components/PortfolioDashboard.tsx create mode 100644 admin-ui/src/styles/portfolio.css create mode 100644 apps/api/metrics.py create mode 100644 dss-claude-plugin/core/local_cache.py create mode 100644 packages/dss-rules/bin/cli.js create mode 100644 packages/dss-rules/bin/init.js create mode 100644 packages/dss-rules/schemas/ds.config.schema.json create mode 100644 packages/dss-rules/templates/ds.config.json create mode 100644 packages/dss-rules/templates/dss-folder/.gitkeep create mode 100644 packages/dss-rules/templates/gitea-workflow.yml create mode 100644 packages/dss-rules/templates/github-workflow.yml create mode 100644 packages/dss-rules/templates/gitignore-additions.txt create mode 100644 packages/dss-rules/templates/gitlab-ci.yml diff --git a/admin-ui/src/components/PortfolioDashboard.tsx b/admin-ui/src/components/PortfolioDashboard.tsx new file mode 100644 index 0000000..d35e674 --- /dev/null +++ b/admin-ui/src/components/PortfolioDashboard.tsx @@ -0,0 +1,424 @@ +import { JSX } from 'preact'; +import { useState, useEffect } from 'preact/hooks'; +import { Card, CardHeader, CardContent } from './base/Card'; +import { Badge } from './base/Badge'; +import { Button } from './base/Button'; +import { Spinner } from './base/Spinner'; + +interface ProjectMetrics { + project: string; + total_files: number; + passed_files: number; + failed_files: number; + total_errors: number; + total_warnings: number; + rules_version: string; + last_updated: string; + adoption_score: number; +} + +interface PortfolioData { + total_projects: number; + projects_passing: number; + projects_failing: number; + total_errors: number; + total_warnings: number; + average_adoption_score: number; + projects: ProjectMetrics[]; +} + +interface ViolationLocation { + file: string; + rule: string; + line: number; + column: number; + severity: string; +} + +interface TrendDataPoint { + date: string; + errors: number; + warnings: number; + pass_rate: number; +} + +const API_BASE = '/api/metrics'; + +export function PortfolioDashboard(): JSX.Element { + const [loading, setLoading] = useState(true); + const [portfolio, setPortfolio] = useState(null); + const [selectedProject, setSelectedProject] = useState(null); + const [projectDetails, setProjectDetails] = useState(null); + const [trends, setTrends] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + loadPortfolio(); + loadTrends(); + }, []); + + async function loadPortfolio() { + setLoading(true); + setError(null); + try { + const response = await fetch(`${API_BASE}/portfolio?days=30`); + if (!response.ok) throw new Error('Failed to load portfolio data'); + const data = await response.json(); + setPortfolio(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + } + + async function loadTrends() { + try { + const response = await fetch(`${API_BASE}/trends?days=30`); + if (!response.ok) return; + const data = await response.json(); + setTrends(data.data || []); + } catch (err) { + console.error('Failed to load trends:', err); + } + } + + async function loadProjectDetails(projectName: string) { + setSelectedProject(projectName); + try { + const response = await fetch(`${API_BASE}/projects/${encodeURIComponent(projectName)}`); + if (!response.ok) throw new Error('Failed to load project details'); + const data = await response.json(); + setProjectDetails(data); + } catch (err) { + console.error('Failed to load project details:', err); + setProjectDetails(null); + } + } + + if (loading) { + return ( +
+ + Loading portfolio metrics... +
+ ); + } + + if (error) { + return ( +
+ + +
+ Error +

{error}

+ +
+
+
+
+ ); + } + + if (!portfolio) { + return ( +
+ + +

No portfolio data available. Run DSS validation in your CI pipelines to collect metrics.

+
+
+
+ ); + } + + return ( +
+
+

Design System Portfolio

+

Adoption metrics across {portfolio.total_projects} projects

+ +
+ + {/* Portfolio Summary */} +
+ + + 0 ? 'error' : 'default'} + /> + = 80 ? 'success' : portfolio.average_adoption_score >= 60 ? 'warning' : 'error'} + /> + 0 ? 'error' : 'success'} + /> + 0 ? 'warning' : 'default'} + /> +
+ + {/* Trend Chart (simple text-based for now) */} + {trends.length > 0 && ( + + + +
+ {trends.slice(-7).map((point, idx) => ( +
+
{point.date.slice(5)}
+
+ + {point.errors > 0 && 'ā—'.repeat(Math.min(point.errors, 10))} + + + {point.warnings > 0 && 'ā—‹'.repeat(Math.min(Math.ceil(point.warnings / 10), 10))} + +
+
{point.pass_rate}%
+
+ ))} +
+
+
+ )} + + {/* Projects Table */} + + + +
+
+ Project + Score + Files + Errors + Warnings + Rules + Updated +
+ {portfolio.projects.map(project => ( +
loadProjectDetails(project.project)} + > + {formatProjectName(project.project)} + + = 80 ? 'success' : project.adoption_score >= 60 ? 'warning' : 'error'} + size="sm" + > + {project.adoption_score.toFixed(0)}% + + + {project.passed_files}/{project.total_files} + + {project.total_errors > 0 ? ( + {project.total_errors} + ) : ( + 0 + )} + + + {project.total_warnings > 0 ? ( + {project.total_warnings} + ) : ( + 0 + )} + + {project.rules_version} + {formatTimeAgo(project.last_updated)} +
+ ))} +
+
+
+ + {/* Project Details Panel */} + {selectedProject && projectDetails && ( + setSelectedProject(null)} + /> + )} +
+ ); +} + +interface MetricCardProps { + label: string; + value: string | number; + variant?: 'default' | 'success' | 'warning' | 'error'; +} + +function MetricCard({ label, value, variant = 'default' }: MetricCardProps): JSX.Element { + return ( + +
+ {label} + {value} +
+
+ ); +} + +interface ProjectDetailsPanelProps { + project: string; + details: any; + onClose: () => void; +} + +function ProjectDetailsPanel({ project, details, onClose }: ProjectDetailsPanelProps): JSX.Element { + const [activeTab, setActiveTab] = useState<'overview' | 'violations' | 'history'>('overview'); + + return ( + + Ɨ} + /> + + {/* Tabs */} +
+ + + +
+ + {/* Tab Content */} + {activeTab === 'overview' && details.latest && ( +
+
+
+ Branch + {details.latest.branch} +
+
+ Commit + {details.latest.commit?.slice(0, 7)} +
+
+ Rules Version + {details.latest.rules_version} +
+
+ Adoption Score + {details.latest.adoption_score?.toFixed(1)}% +
+
+ + {/* Violations by Rule */} + {details.violations_by_rule && Object.keys(details.violations_by_rule).length > 0 && ( +
+

Violations by Rule

+ {Object.entries(details.violations_by_rule).map(([rule, count]) => ( +
+ {rule} + {count as number} +
+ ))} +
+ )} +
+ )} + + {activeTab === 'violations' && ( +
+ {details.violation_locations?.length === 0 ? ( +

No violations found

+ ) : ( + details.violation_locations?.map((v: ViolationLocation, idx: number) => ( +
+
+ {v.file}:{v.line}:{v.column} +
+
+ + {v.rule} + +
+
+ )) + )} +
+ )} + + {activeTab === 'history' && ( +
+ {details.history?.map((h: any, idx: number) => ( +
+ {h.commit?.slice(0, 7)} + {h.branch} + + {h.errors > 0 ? {h.errors} : '0'} + + {formatTimeAgo(h.timestamp)} +
+ ))} +
+ )} +
+
+ ); +} + +// Utility functions +function formatProjectName(name: string): string { + // Format "org/repo" or just "repo" + const parts = name.split('/'); + return parts[parts.length - 1]; +} + +function formatTimeAgo(timestamp: string): string { + if (!timestamp) return 'Unknown'; + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +export default PortfolioDashboard; diff --git a/admin-ui/src/styles/portfolio.css b/admin-ui/src/styles/portfolio.css new file mode 100644 index 0000000..e49e923 --- /dev/null +++ b/admin-ui/src/styles/portfolio.css @@ -0,0 +1,360 @@ +/* Portfolio Dashboard Styles */ + +.portfolio-dashboard { + padding: var(--spacing-lg); + max-width: 1400px; + margin: 0 auto; +} + +.portfolio-header { + display: flex; + align-items: center; + gap: var(--spacing-md); + margin-bottom: var(--spacing-xl); +} + +.portfolio-header h1 { + font-size: var(--font-size-2xl); + font-weight: 600; + margin: 0; +} + +.portfolio-header .subtitle { + color: var(--color-text-muted); + margin: 0; + flex: 1; +} + +.portfolio-loading, +.portfolio-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + gap: var(--spacing-md); +} + +.portfolio-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--spacing-md); + margin-bottom: var(--spacing-xl); +} + +.metric-display { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.metric-label { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin-bottom: var(--spacing-xs); +} + +.metric-value { + font-size: var(--font-size-2xl); + font-weight: 700; +} + +.metric-success .metric-value { + color: var(--color-success); +} + +.metric-warning .metric-value { + color: var(--color-warning); +} + +.metric-error .metric-value { + color: var(--color-error); +} + +/* Trend Chart */ +.trend-chart { + display: flex; + gap: var(--spacing-sm); + overflow-x: auto; + padding: var(--spacing-sm) 0; +} + +.trend-bar { + display: flex; + flex-direction: column; + align-items: center; + min-width: 80px; + padding: var(--spacing-xs); + border-radius: var(--radius-sm); + background: var(--color-bg-secondary); +} + +.trend-date { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + margin-bottom: var(--spacing-xs); +} + +.trend-values { + display: flex; + flex-direction: column; + align-items: center; + min-height: 40px; +} + +.trend-errors { + color: var(--color-error); + font-size: 10px; + letter-spacing: -2px; +} + +.trend-warnings { + color: var(--color-warning); + font-size: 8px; + letter-spacing: -2px; +} + +.trend-rate { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--color-text); +} + +/* Projects Table */ +.projects-table { + display: flex; + flex-direction: column; + gap: 2px; +} + +.table-header, +.table-row { + display: grid; + grid-template-columns: 2fr 80px 80px 80px 80px 80px 100px; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + align-items: center; +} + +.table-header { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + border-bottom: 1px solid var(--color-border); +} + +.table-row { + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.table-row:hover { + background: var(--color-bg-secondary); +} + +.table-row.selected { + background: var(--color-bg-tertiary); + border-left: 3px solid var(--color-primary); +} + +.col-project { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.col-score, +.col-files, +.col-errors, +.col-warnings, +.col-version, +.col-updated { + text-align: center; + font-size: var(--font-size-sm); +} + +.col-updated { + color: var(--color-text-muted); +} + +/* Project Details Panel */ +.project-details-panel { + margin-top: var(--spacing-lg); + animation: slideUp 0.2s ease; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.tabs { + display: flex; + gap: var(--spacing-xs); + border-bottom: 1px solid var(--color-border); + margin-bottom: var(--spacing-md); +} + +.tab { + padding: var(--spacing-sm) var(--spacing-md); + border: none; + background: none; + cursor: pointer; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + border-bottom: 2px solid transparent; + transition: all 0.15s ease; +} + +.tab:hover { + color: var(--color-text); +} + +.tab.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +.tab-content { + padding: var(--spacing-md) 0; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--spacing-md); + margin-bottom: var(--spacing-lg); +} + +.detail-item { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.detail-label { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + text-transform: uppercase; +} + +.detail-value { + font-weight: 500; +} + +.violations-breakdown { + margin-top: var(--spacing-lg); +} + +.violations-breakdown h4 { + font-size: var(--font-size-sm); + font-weight: 600; + margin-bottom: var(--spacing-sm); +} + +.rule-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-xs) 0; + border-bottom: 1px solid var(--color-border-light); +} + +.rule-name { + font-family: var(--font-mono); + font-size: var(--font-size-sm); +} + +/* Violations List */ +.violations-list { + max-height: 400px; + overflow-y: auto; +} + +.violation-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-sm); + border-bottom: 1px solid var(--color-border-light); +} + +.violation-location code { + font-size: var(--font-size-sm); + color: var(--color-text); +} + +/* History List */ +.history-list { + max-height: 300px; + overflow-y: auto; +} + +.history-item { + display: grid; + grid-template-columns: 80px 100px 60px 1fr; + gap: var(--spacing-sm); + padding: var(--spacing-sm) 0; + border-bottom: 1px solid var(--color-border-light); + font-size: var(--font-size-sm); +} + +.history-commit { + font-family: var(--font-mono); +} + +.history-branch { + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; +} + +.history-time { + text-align: right; + color: var(--color-text-muted); +} + +/* Text utilities */ +.text-success { + color: var(--color-success); +} + +.text-warning { + color: var(--color-warning); +} + +.text-error { + color: var(--color-error); +} + +.text-muted { + color: var(--color-text-muted); +} + +/* Responsive */ +@media (max-width: 768px) { + .table-header, + .table-row { + grid-template-columns: 1fr 60px 60px 60px; + } + + .col-version, + .col-updated, + .col-warnings { + display: none; + } + + .portfolio-metrics { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/admin-ui/src/workdesks/UIWorkdesk.tsx b/admin-ui/src/workdesks/UIWorkdesk.tsx index 63c4694..62038f4 100644 --- a/admin-ui/src/workdesks/UIWorkdesk.tsx +++ b/admin-ui/src/workdesks/UIWorkdesk.tsx @@ -7,8 +7,10 @@ import { Input, Select } from '../components/base/Input'; import { Spinner } from '../components/base/Spinner'; import { endpoints } from '../api/client'; import { currentProject } from '../state/project'; +import { PortfolioDashboard } from '../components/PortfolioDashboard'; import type { TokenDrift, Component, FigmaExtractResult } from '../api/types'; import './Workdesk.css'; +import '../styles/portfolio.css'; interface UIWorkdeskProps { activeTool: string | null; @@ -26,6 +28,7 @@ export default function UIWorkdesk({ activeTool }: UIWorkdeskProps) { 'code-generator': , 'quick-wins': , 'token-drift': , + 'portfolio': , }; return toolViews[activeTool] || ; diff --git a/apps/api/metrics.py b/apps/api/metrics.py new file mode 100644 index 0000000..b4cb87e --- /dev/null +++ b/apps/api/metrics.py @@ -0,0 +1,663 @@ +""" +DSS Metrics API Module. + +Handles metrics collection from CI pipelines and provides dashboard data +for UI designers to view portfolio-wide design system adoption. + +Enterprise Architecture: +- Tier 1 (Dashboard): Read-only aggregated metrics for UI designers +- Receives uploads from Tier 2 (CI/CD pipelines) +- No write operations from dashboard - only CI uploads +""" + +import json +import os +import sqlite3 +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Header, HTTPException, Query +from pydantic import BaseModel + +# Router for metrics endpoints +router = APIRouter(prefix="/api/metrics", tags=["metrics"]) + +# Database path +DB_PATH = Path(os.getenv("DSS_DB_PATH", Path.home() / ".dss" / "metrics.db")) + + +# === Pydantic Models === + + +class ViolationLocation(BaseModel): + """Location of a rule violation in source code.""" + + rule: str + line: int + column: Optional[int] = None + file: Optional[str] = None + + +class FileMetrics(BaseModel): + """Metrics for a single file.""" + + file: str + errors: int + warnings: int + violations: List[ViolationLocation] = [] + + +class MetricsUpload(BaseModel): + """Metrics payload uploaded from CI.""" + + project: str + branch: str + commit: str + timestamp: Optional[str] = None + metrics: Dict[str, Any] + fileResults: Optional[List[FileMetrics]] = [] + + +class ProjectMetricsSummary(BaseModel): + """Summary metrics for a project.""" + + project: str + total_files: int + passed_files: int + failed_files: int + total_errors: int + total_warnings: int + rules_version: str + last_updated: str + adoption_score: float + + +class PortfolioMetrics(BaseModel): + """Portfolio-wide metrics aggregation.""" + + total_projects: int + projects_passing: int + projects_failing: int + total_errors: int + total_warnings: int + average_adoption_score: float + projects: List[ProjectMetricsSummary] + + +# === Database Setup === + + +def init_db(): + """Initialize the metrics database.""" + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + + # Metrics uploads table + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS metrics_uploads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project TEXT NOT NULL, + branch TEXT NOT NULL, + commit_sha TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + total_files INTEGER DEFAULT 0, + passed_files INTEGER DEFAULT 0, + failed_files INTEGER DEFAULT 0, + total_errors INTEGER DEFAULT 0, + total_warnings INTEGER DEFAULT 0, + rules_version TEXT, + raw_data JSON + ) + """ + ) + + # Violations table for detailed tracking + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS violations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + upload_id INTEGER NOT NULL, + project TEXT NOT NULL, + file_path TEXT NOT NULL, + rule TEXT NOT NULL, + line INTEGER, + column_num INTEGER, + severity TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (upload_id) REFERENCES metrics_uploads(id) + ) + """ + ) + + # Component usage tracking for UI designers + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS component_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project TEXT NOT NULL, + component_name TEXT NOT NULL, + file_path TEXT NOT NULL, + line INTEGER, + import_source TEXT, + is_ds_component BOOLEAN DEFAULT 0, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + # Indexes for performance + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_uploads_project ON metrics_uploads(project)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_uploads_timestamp ON metrics_uploads(timestamp)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_violations_project ON violations(project)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_component_project ON component_usage(project)" + ) + + conn.commit() + conn.close() + + +# Initialize on module load +init_db() + + +# === Helper Functions === + + +def get_db(): + """Get database connection.""" + return sqlite3.connect(str(DB_PATH)) + + +def calculate_adoption_score(passed: int, total: int, errors: int) -> float: + """Calculate adoption score (0-100).""" + if total == 0: + return 100.0 + base_score = (passed / total) * 100 + # Penalty for errors + penalty = min(errors * 2, 50) + return max(0, base_score - penalty) + + +# === API Endpoints === + + +@router.post("/upload") +async def upload_metrics( + payload: MetricsUpload, + authorization: Optional[str] = Header(None), +): + """ + Upload metrics from CI pipeline. + + This is the only write endpoint - called by CI after validation runs. + Authentication via DSS_API_TOKEN in Authorization header. + """ + # Validate token (if configured) + expected_token = os.getenv("DSS_API_TOKEN") + if expected_token: + if not authorization: + raise HTTPException(status_code=401, detail="Authorization required") + token = authorization.replace("Bearer ", "") + if token != expected_token: + raise HTTPException(status_code=403, detail="Invalid token") + + conn = get_db() + cursor = conn.cursor() + + try: + # Extract metrics + metrics = payload.metrics + total_files = metrics.get("totalFiles", 0) + passed_files = metrics.get("passedFiles", 0) + failed_files = metrics.get("failedFiles", 0) + total_errors = metrics.get("totalErrors", 0) + total_warnings = metrics.get("totalWarnings", 0) + rules_version = metrics.get("rulesVersion", "unknown") + + # Insert main metrics record + cursor.execute( + """ + INSERT INTO metrics_uploads + (project, branch, commit_sha, total_files, passed_files, failed_files, + total_errors, total_warnings, rules_version, raw_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + payload.project, + payload.branch, + payload.commit, + total_files, + passed_files, + failed_files, + total_errors, + total_warnings, + rules_version, + json.dumps(payload.model_dump()), + ), + ) + + upload_id = cursor.lastrowid + + # Insert violations with file locations + if payload.fileResults: + for file_result in payload.fileResults: + for violation in file_result.violations: + cursor.execute( + """ + INSERT INTO violations + (upload_id, project, file_path, rule, line, column_num, severity) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + upload_id, + payload.project, + file_result.file, + violation.rule, + violation.line, + violation.column, + "error" if "error" in violation.rule.lower() else "warning", + ), + ) + + conn.commit() + + return { + "status": "success", + "upload_id": upload_id, + "project": payload.project, + "metrics": { + "files": total_files, + "errors": total_errors, + "warnings": total_warnings, + }, + } + + except Exception as e: + conn.rollback() + raise HTTPException(status_code=500, detail=f"Failed to store metrics: {str(e)}") + finally: + conn.close() + + +@router.get("/portfolio") +async def get_portfolio_metrics( + days: int = Query(default=30, description="Number of days to include"), +): + """ + Get portfolio-wide metrics aggregation. + + Returns summary for all projects - designed for UI designer dashboard. + """ + conn = get_db() + cursor = conn.cursor() + + try: + # Get latest metrics for each project + cursor.execute( + """ + SELECT + project, + total_files, + passed_files, + failed_files, + total_errors, + total_warnings, + rules_version, + MAX(timestamp) as last_updated + FROM metrics_uploads + WHERE timestamp > datetime('now', ?) + GROUP BY project + ORDER BY last_updated DESC + """, + (f"-{days} days",), + ) + + rows = cursor.fetchall() + + projects = [] + total_errors = 0 + total_warnings = 0 + projects_passing = 0 + + for row in rows: + ( + project, + total_files, + passed_files, + failed_files, + errors, + warnings, + rules_version, + last_updated, + ) = row + + adoption_score = calculate_adoption_score(passed_files, total_files, errors) + + projects.append( + ProjectMetricsSummary( + project=project, + total_files=total_files, + passed_files=passed_files, + failed_files=failed_files, + total_errors=errors, + total_warnings=warnings, + rules_version=rules_version or "unknown", + last_updated=last_updated, + adoption_score=adoption_score, + ) + ) + + total_errors += errors + total_warnings += warnings + if errors == 0: + projects_passing += 1 + + avg_score = ( + sum(p.adoption_score for p in projects) / len(projects) if projects else 0 + ) + + return PortfolioMetrics( + total_projects=len(projects), + projects_passing=projects_passing, + projects_failing=len(projects) - projects_passing, + total_errors=total_errors, + total_warnings=total_warnings, + average_adoption_score=round(avg_score, 1), + projects=projects, + ) + + finally: + conn.close() + + +@router.get("/projects/{project_name}") +async def get_project_metrics( + project_name: str, + limit: int = Query(default=10, description="Number of recent builds"), +): + """ + Get detailed metrics for a specific project. + + Includes historical data and violation breakdown. + """ + conn = get_db() + cursor = conn.cursor() + + try: + # Get recent builds + cursor.execute( + """ + SELECT + id, branch, commit_sha, timestamp, + total_files, passed_files, failed_files, + total_errors, total_warnings, rules_version + FROM metrics_uploads + WHERE project = ? + ORDER BY timestamp DESC + LIMIT ? + """, + (project_name, limit), + ) + + builds = cursor.fetchall() + + if not builds: + raise HTTPException(status_code=404, detail="Project not found") + + # Get violation breakdown for latest build + latest_id = builds[0][0] + cursor.execute( + """ + SELECT rule, COUNT(*) as count + FROM violations + WHERE upload_id = ? + GROUP BY rule + ORDER BY count DESC + """, + (latest_id,), + ) + + violations_by_rule = dict(cursor.fetchall()) + + # Get file locations for violations (for UI designer "where is this used?") + cursor.execute( + """ + SELECT file_path, rule, line, column_num + FROM violations + WHERE upload_id = ? + ORDER BY file_path, line + """, + (latest_id,), + ) + + violation_locations = [ + { + "file": row[0], + "rule": row[1], + "line": row[2], + "column": row[3], + } + for row in cursor.fetchall() + ] + + return { + "project": project_name, + "latest": { + "branch": builds[0][1], + "commit": builds[0][2], + "timestamp": builds[0][3], + "total_files": builds[0][4], + "passed_files": builds[0][5], + "failed_files": builds[0][6], + "total_errors": builds[0][7], + "total_warnings": builds[0][8], + "rules_version": builds[0][9], + "adoption_score": calculate_adoption_score( + builds[0][5], builds[0][4], builds[0][7] + ), + }, + "violations_by_rule": violations_by_rule, + "violation_locations": violation_locations, + "history": [ + { + "branch": b[1], + "commit": b[2], + "timestamp": b[3], + "errors": b[7], + "warnings": b[8], + } + for b in builds + ], + } + + finally: + conn.close() + + +@router.get("/projects/{project_name}/violations") +async def get_project_violations( + project_name: str, + rule: Optional[str] = Query(default=None, description="Filter by rule"), + file_pattern: Optional[str] = Query(default=None, description="Filter by file pattern"), +): + """ + Get detailed violation locations for a project. + + Designed for UI designers to answer "Where is Button component used?" + """ + conn = get_db() + cursor = conn.cursor() + + try: + # Get latest upload for project + cursor.execute( + """ + SELECT id FROM metrics_uploads + WHERE project = ? + ORDER BY timestamp DESC + LIMIT 1 + """, + (project_name,), + ) + + row = cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Project not found") + + upload_id = row[0] + + # Build query with optional filters + query = """ + SELECT file_path, rule, line, column_num, severity + FROM violations + WHERE upload_id = ? + """ + params = [upload_id] + + if rule: + query += " AND rule LIKE ?" + params.append(f"%{rule}%") + + if file_pattern: + query += " AND file_path LIKE ?" + params.append(f"%{file_pattern}%") + + query += " ORDER BY file_path, line" + + cursor.execute(query, params) + + return { + "project": project_name, + "violations": [ + { + "file": row[0], + "rule": row[1], + "line": row[2], + "column": row[3], + "severity": row[4], + } + for row in cursor.fetchall() + ], + } + + finally: + conn.close() + + +@router.get("/trends") +async def get_trends( + project: Optional[str] = Query(default=None, description="Filter by project"), + days: int = Query(default=30, description="Number of days"), +): + """ + Get trend data for charts. + + Shows error/warning counts over time for portfolio or specific project. + """ + conn = get_db() + cursor = conn.cursor() + + try: + if project: + cursor.execute( + """ + SELECT + DATE(timestamp) as date, + SUM(total_errors) as errors, + SUM(total_warnings) as warnings, + AVG(passed_files * 100.0 / NULLIF(total_files, 0)) as pass_rate + FROM metrics_uploads + WHERE project = ? AND timestamp > datetime('now', ?) + GROUP BY DATE(timestamp) + ORDER BY date + """, + (project, f"-{days} days"), + ) + else: + cursor.execute( + """ + SELECT + DATE(timestamp) as date, + SUM(total_errors) as errors, + SUM(total_warnings) as warnings, + AVG(passed_files * 100.0 / NULLIF(total_files, 0)) as pass_rate + FROM metrics_uploads + WHERE timestamp > datetime('now', ?) + GROUP BY DATE(timestamp) + ORDER BY date + """, + (f"-{days} days",), + ) + + return { + "project": project or "portfolio", + "days": days, + "data": [ + { + "date": row[0], + "errors": row[1] or 0, + "warnings": row[2] or 0, + "pass_rate": round(row[3] or 0, 1), + } + for row in cursor.fetchall() + ], + } + + finally: + conn.close() + + +@router.get("/rules/usage") +async def get_rules_usage( + days: int = Query(default=30, description="Number of days"), +): + """ + Get rule violation statistics across all projects. + + Shows which rules are violated most often - useful for identifying + common patterns and potential training needs. + """ + conn = get_db() + cursor = conn.cursor() + + try: + cursor.execute( + """ + SELECT + rule, + COUNT(*) as total_violations, + COUNT(DISTINCT project) as affected_projects + FROM violations v + JOIN metrics_uploads m ON v.upload_id = m.id + WHERE m.timestamp > datetime('now', ?) + GROUP BY rule + ORDER BY total_violations DESC + """, + (f"-{days} days",), + ) + + return { + "days": days, + "rules": [ + { + "rule": row[0], + "total_violations": row[1], + "affected_projects": row[2], + } + for row in cursor.fetchall() + ], + } + + finally: + conn.close() diff --git a/apps/api/server.py b/apps/api/server.py index 6d03925..50aadc5 100644 --- a/apps/api/server.py +++ b/apps/api/server.py @@ -32,6 +32,7 @@ from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from apps.api.browser_logger import router as browser_log_router +from apps.api.metrics import router as metrics_router from dss import settings # Load environment variables from .env file FIRST (before any other imports) @@ -313,6 +314,9 @@ app.add_middleware( # Include browser logger router for console log forwarding app.include_router(browser_log_router) +# Include metrics router for CI pipeline uploads and dashboard +app.include_router(metrics_router) + # Mount Admin UI static files UI_DIR = Path(__file__).parent.parent.parent / "admin-ui" if UI_DIR.exists(): diff --git a/dss-claude-plugin/core/__init__.py b/dss-claude-plugin/core/__init__.py index 803753d..d206033 100644 --- a/dss-claude-plugin/core/__init__.py +++ b/dss-claude-plugin/core/__init__.py @@ -2,11 +2,21 @@ DSS Core Module - Configuration and Context Management. Extended with Context Compiler for design system context resolution. + +Enterprise Architecture: +- LOCAL mode: Uses LocalAnalysisCache for fast, offline-capable validation +- REMOTE mode: Full analysis via API +- CI mode: Authoritative enforcement, uploads metrics to dashboard """ from .compiler import EMERGENCY_SKIN, ContextCompiler from .config import DSSConfig, DSSMode from .context import DSSContext +from .local_cache import ( + LocalAnalysisCache, + LocalCacheValidator, + get_project_cache, +) from .mcp_extensions import ( COMPILER, get_active_context, @@ -23,6 +33,9 @@ __all__ = [ "DSSContext", "ContextCompiler", "EMERGENCY_SKIN", + "LocalAnalysisCache", + "LocalCacheValidator", + "get_project_cache", "get_active_context", "resolve_token", "validate_manifest", diff --git a/dss-claude-plugin/core/config.py b/dss-claude-plugin/core/config.py index 8a53019..38caef8 100644 --- a/dss-claude-plugin/core/config.py +++ b/dss-claude-plugin/core/config.py @@ -3,8 +3,14 @@ DSS Configuration Module ======================== Handles configuration management for the Design System Server (DSS) Claude Plugin. -Supports local/remote mode detection, persistent configuration storage, and +Supports local/remote/CI mode detection, persistent configuration storage, and environment variable overrides. + +Enterprise Architecture: +- LOCAL: Developer workstation, reads from .dss/ cache, advisory validation +- REMOTE: Headless/server mode, full analysis, metrics upload +- CI: CI/CD pipeline, authoritative enforcement, blocking validation +- AUTO: Detect environment automatically (CI env vars -> CI, else LOCAL with cache) """ import json @@ -13,6 +19,7 @@ import os import uuid from enum import Enum from pathlib import Path +from typing import Optional import aiohttp from pydantic import BaseModel, Field, ValidationError @@ -24,14 +31,28 @@ CONFIG_DIR = Path.home() / ".dss" CONFIG_FILE = CONFIG_DIR / "config.json" DEFAULT_REMOTE_URL = "https://dss.overbits.luz.uy" DEFAULT_LOCAL_URL = "http://localhost:6006" +DEFAULT_DASHBOARD_URL = "https://dss.overbits.luz.uy/api/metrics" + +# CI environment variables that indicate we're running in a pipeline +CI_ENV_VARS = [ + "CI", + "GITEA_ACTIONS", + "GITHUB_ACTIONS", + "GITLAB_CI", + "JENKINS_URL", + "CIRCLECI", + "TRAVIS", + "BUILDKITE", +] class DSSMode(str, Enum): """Operation modes for the DSS plugin.""" - LOCAL = "local" - REMOTE = "remote" - AUTO = "auto" + LOCAL = "local" # Developer workstation - advisory, uses cache + REMOTE = "remote" # Headless server - full analysis + CI = "ci" # CI/CD pipeline - authoritative enforcement + AUTO = "auto" # Auto-detect based on environment class DSSConfig(BaseModel): @@ -42,15 +63,21 @@ class DSSConfig(BaseModel): mode (DSSMode): The configured operation mode (default: AUTO). remote_url (str): URL for the remote DSS API. local_url (str): URL for the local DSS API (usually localhost). + dashboard_url (str): URL for metrics dashboard API. session_id (str): Unique identifier for this client instance. + project_path (str): Current project path (for local analysis). + rules_version (str): Pinned @dss/rules version for this project. """ mode: DSSMode = Field(default=DSSMode.AUTO, description="Operation mode preference") remote_url: str = Field(default=DEFAULT_REMOTE_URL, description="Remote API endpoint") local_url: str = Field(default=DEFAULT_LOCAL_URL, description="Local API endpoint") + dashboard_url: str = Field(default=DEFAULT_DASHBOARD_URL, description="Metrics dashboard API") session_id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Persistent session ID" ) + project_path: Optional[str] = Field(default=None, description="Current project path") + rules_version: Optional[str] = Field(default=None, description="Pinned @dss/rules version") class Config: validate_assignment = True @@ -101,38 +128,75 @@ class DSSConfig(BaseModel): Determine the actual runtime mode based on priority rules. Priority: - 1. DSS_MODE environment variable - 2. Configured 'mode' (if not AUTO) - 3. Auto-detection (ping local health endpoint) - 4. Fallback to REMOTE + 1. DSS_MODE environment variable (explicit override) + 2. CI environment detection (GITEA_ACTIONS, CI, GITHUB_ACTIONS, etc.) + 3. Configured 'mode' (if not AUTO) + 4. Auto-detection (check for .dss/ folder, ping local health) + 5. Fallback to LOCAL (developer-first) Returns: - DSSMode: The resolved active mode (LOCAL or REMOTE). + DSSMode: The resolved active mode (LOCAL, REMOTE, or CI). """ - # 1. Check Environment Variable + # 1. Check Environment Variable (explicit override) env_mode = os.getenv("DSS_MODE") if env_mode: try: - # Normalize string to enum - return DSSMode(env_mode.lower()) + resolved = DSSMode(env_mode.lower()) + logger.info(f"Mode set via DSS_MODE env var: {resolved.value}") + return resolved except ValueError: logger.warning(f"Invalid DSS_MODE env var '{env_mode}', ignoring.") - # 2. Check Configuration (if explicit) + # 2. Check CI environment variables + if self._is_ci_environment(): + logger.info("CI environment detected. Using CI mode (authoritative enforcement).") + return DSSMode.CI + + # 3. Check Configuration (if explicit, not AUTO) if self.mode != DSSMode.AUTO: + logger.info(f"Using configured mode: {self.mode.value}") return self.mode - # 3. Auto-detect + # 4. Auto-detect based on environment logger.info("Auto-detecting DSS mode...") - is_local_healthy = await self._check_local_health() - if is_local_healthy: - logger.info(f"Local server detected at {self.local_url}. Switching to LOCAL mode.") + # Check for local .dss/ folder (indicates project setup) + if self._has_local_dss_folder(): + logger.info("Found .dss/ folder. Using LOCAL mode with cache.") return DSSMode.LOCAL - else: - logger.info("Local server unreachable. Fallback to REMOTE mode.") - # 4. Fallback - return DSSMode.REMOTE + + # Check if local server is running + is_local_healthy = await self._check_local_health() + if is_local_healthy: + logger.info(f"Local server detected at {self.local_url}. Using LOCAL mode.") + return DSSMode.LOCAL + + # 5. Fallback to LOCAL (developer-first, will use stale cache if available) + logger.info("Fallback to LOCAL mode (offline-capable with cache).") + return DSSMode.LOCAL + + def _is_ci_environment(self) -> bool: + """Check if running in a CI/CD environment.""" + for env_var in CI_ENV_VARS: + if os.getenv(env_var): + logger.debug(f"CI detected via {env_var} env var") + return True + return False + + def _has_local_dss_folder(self) -> bool: + """Check if current directory or project has .dss/ folder.""" + # Check current working directory + cwd_dss = Path.cwd() / ".dss" + if cwd_dss.exists() and cwd_dss.is_dir(): + return True + + # Check configured project path + if self.project_path: + project_dss = Path(self.project_path) / ".dss" + if project_dss.exists() and project_dss.is_dir(): + return True + + return False async def _check_local_health(self) -> bool: """ @@ -161,3 +225,46 @@ class DSSConfig(BaseModel): if active_mode == DSSMode.LOCAL: return self.local_url return self.remote_url + + def get_mode_behavior(self, active_mode: DSSMode) -> dict: + """ + Get behavior configuration for the active mode. + + Returns dict with: + - blocking: Whether validation errors block operations + - upload_metrics: Whether to upload metrics to dashboard + - use_cache: Whether to use local .dss/ cache + - cache_ttl: Cache time-to-live in seconds + """ + behaviors = { + DSSMode.LOCAL: { + "blocking": False, # Advisory only + "upload_metrics": False, + "use_cache": True, + "cache_ttl": 3600, # 1 hour + "show_stale_warning": True, + }, + DSSMode.REMOTE: { + "blocking": True, + "upload_metrics": True, + "use_cache": False, + "cache_ttl": 0, + "show_stale_warning": False, + }, + DSSMode.CI: { + "blocking": True, # Authoritative enforcement + "upload_metrics": True, + "use_cache": False, + "cache_ttl": 0, + "show_stale_warning": False, + }, + DSSMode.AUTO: { + # AUTO resolves to another mode, shouldn't reach here + "blocking": False, + "upload_metrics": False, + "use_cache": True, + "cache_ttl": 3600, + "show_stale_warning": True, + }, + } + return behaviors.get(active_mode, behaviors[DSSMode.LOCAL]) diff --git a/dss-claude-plugin/core/context.py b/dss-claude-plugin/core/context.py index 8819b43..d43b5be 100644 --- a/dss-claude-plugin/core/context.py +++ b/dss-claude-plugin/core/context.py @@ -4,6 +4,11 @@ DSS Context Module Singleton context manager for the DSS Plugin. Handles configuration loading, mode detection, and strategy instantiation. + +Enterprise Architecture: +- LOCAL: Uses LocalAnalysisCache for fast, offline-capable validation +- REMOTE: Full analysis via API +- CI: Authoritative enforcement, uploads metrics to dashboard """ import asyncio @@ -11,6 +16,7 @@ import logging from typing import Any, Dict, Optional from .config import DSSConfig, DSSMode +from .local_cache import LocalAnalysisCache, LocalCacheValidator, get_project_cache # Logger setup logger = logging.getLogger(__name__) @@ -44,6 +50,8 @@ class DSSContext: self._capabilities: Dict[str, bool] = {} self._strategy_cache: Dict[str, Strategy] = {} self.session_id: Optional[str] = None + self._local_cache: Optional[LocalAnalysisCache] = None + self._cache_validator: Optional[LocalCacheValidator] = None @classmethod async def get_instance(cls) -> "DSSContext": @@ -91,7 +99,11 @@ class DSSContext: f"DSSContext initialized. Mode: {self.active_mode.value}, Session: {self.session_id}" ) - # 3. Cache Capabilities + # 3. Initialize local cache for LOCAL mode + if self.active_mode == DSSMode.LOCAL: + self._init_local_cache() + + # 4. Cache Capabilities self._cache_capabilities() except Exception as e: @@ -100,6 +112,27 @@ class DSSContext: self.active_mode = DSSMode.REMOTE self._capabilities = {"limited": True} + def _init_local_cache(self) -> None: + """Initialize local cache for LOCAL mode.""" + try: + project_path = self.config.project_path if self.config else None + self._local_cache = get_project_cache(project_path) + self._cache_validator = LocalCacheValidator(self._local_cache) + + # Log cache status + status = self._local_cache.get_cache_status() + if status.get("exists"): + if status.get("is_stale"): + logger.warning(f"Local cache is stale: {status.get('recommendation')}") + else: + logger.info(f"Local cache ready. Rules version: {status.get('rules_version')}") + else: + logger.info("No local cache found. Run `npx dss-rules validate` to populate.") + except Exception as e: + logger.warning(f"Failed to initialize local cache: {e}") + self._local_cache = None + self._cache_validator = None + def _cache_capabilities(self) -> None: """Determines what the plugin can do based on the active mode.""" # Base capabilities @@ -192,3 +225,88 @@ class DSSContext: # Cache and return self._strategy_cache[strategy_type] = strategy_instance return strategy_instance + + # === Local Cache Access Methods === + + def get_local_cache(self) -> Optional[LocalAnalysisCache]: + """ + Get the local analysis cache instance. + + Returns: + LocalAnalysisCache instance or None if not in LOCAL mode. + """ + return self._local_cache + + def get_cache_validator(self) -> Optional[LocalCacheValidator]: + """ + Get the local cache validator instance. + + Returns: + LocalCacheValidator instance or None if not in LOCAL mode. + """ + return self._cache_validator + + def get_cache_status(self) -> Dict[str, Any]: + """ + Get current cache status. + + Returns: + Cache status dict with freshness info and recommendations. + """ + if self._local_cache is None: + return { + "available": False, + "mode": self.active_mode.value, + "message": f"Local cache not available in {self.active_mode.value} mode" + } + + status = self._local_cache.get_cache_status() + status["available"] = True + status["mode"] = self.active_mode.value + return status + + def validate_file_local(self, file_path: str) -> Dict[str, Any]: + """ + Validate a file using local cache (LOCAL mode only). + + Args: + file_path: Path to file to validate. + + Returns: + Validation result dict. + """ + if self._cache_validator is None: + return { + "file": file_path, + "error": "Local cache not available", + "mode": self.active_mode.value + } + + return self._cache_validator.validate_file(file_path) + + def get_validation_summary(self) -> Dict[str, Any]: + """ + Get summary of validation state from local cache. + + Returns: + Summary dict with counts and status. + """ + if self._cache_validator is None: + return { + "error": "Local cache not available", + "mode": self.active_mode.value + } + + return self._cache_validator.get_summary() + + def get_mode_behavior(self) -> Dict[str, Any]: + """ + Get behavior configuration for current mode. + + Returns: + Dict with blocking, upload_metrics, use_cache flags. + """ + if self.config is None: + return {"blocking": False, "upload_metrics": False, "use_cache": False} + + return self.config.get_mode_behavior(self.active_mode) diff --git a/dss-claude-plugin/core/local_cache.py b/dss-claude-plugin/core/local_cache.py new file mode 100644 index 0000000..7016d2b --- /dev/null +++ b/dss-claude-plugin/core/local_cache.py @@ -0,0 +1,402 @@ +""" +DSS Local Analysis Cache Module. + +Handles reading and writing to the local .dss/ folder for developer workstation mode. +Provides offline-capable validation using cached analysis results. + +Enterprise Architecture: +- LOCAL mode reads from .dss/cache/ for fast, offline-capable feedback +- Cache is populated by `dss-rules validate` or periodic sync +- Stale cache shows warnings but doesn't block (advisory mode) +""" + +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# Cache file names within .dss/ +ANALYSIS_CACHE_FILE = "analysis_cache.json" +RULES_CACHE_FILE = "rules_cache.json" +VIOLATIONS_CACHE_FILE = "violations_cache.json" +METADATA_FILE = "metadata.json" + +# Default cache TTL in seconds (1 hour) +DEFAULT_CACHE_TTL = 3600 + +# Stale cache threshold (24 hours - show warning but still use) +STALE_THRESHOLD = 86400 + + +class LocalAnalysisCache: + """ + Manages local .dss/ folder cache for developer workstations. + + Provides: + - Fast, offline-capable validation results + - Cached rule definitions from @dss/rules + - Violation history for incremental feedback + """ + + def __init__(self, project_path: Optional[str] = None): + """ + Initialize cache with project path. + + Args: + project_path: Path to project root. Defaults to current directory. + """ + self.project_path = Path(project_path) if project_path else Path.cwd() + self.dss_dir = self.project_path / ".dss" + self.cache_dir = self.dss_dir / "cache" + self._ensure_structure() + + def _ensure_structure(self) -> None: + """Ensure .dss/ folder structure exists.""" + try: + self.dss_dir.mkdir(parents=True, exist_ok=True) + self.cache_dir.mkdir(parents=True, exist_ok=True) + + # Create .gitignore if it doesn't exist + gitignore_path = self.dss_dir / ".gitignore" + if not gitignore_path.exists(): + gitignore_path.write_text("# DSS local cache - do not commit\n*\n!.gitignore\n") + logger.debug(f"Created .gitignore in {self.dss_dir}") + except Exception as e: + logger.warning(f"Failed to create .dss/ structure: {e}") + + def get_cache_status(self) -> Dict[str, Any]: + """ + Get current cache status including freshness. + + Returns: + Dict with cache status, age, and recommendation. + """ + metadata = self._read_metadata() + + if not metadata: + return { + "exists": False, + "age_seconds": None, + "is_fresh": False, + "is_stale": True, + "recommendation": "Run `npx dss-rules validate` to populate cache" + } + + last_updated = metadata.get("last_updated") + if not last_updated: + return { + "exists": True, + "age_seconds": None, + "is_fresh": False, + "is_stale": True, + "recommendation": "Cache missing timestamp, run validation" + } + + try: + last_dt = datetime.fromisoformat(last_updated.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + age_seconds = (now - last_dt).total_seconds() + + is_fresh = age_seconds < DEFAULT_CACHE_TTL + is_stale = age_seconds > STALE_THRESHOLD + + if is_fresh: + recommendation = "Cache is fresh" + elif is_stale: + recommendation = f"Cache is {int(age_seconds / 3600)}h old. Run `npx dss-rules validate` to refresh" + else: + recommendation = "Cache is usable but consider refreshing" + + return { + "exists": True, + "age_seconds": int(age_seconds), + "is_fresh": is_fresh, + "is_stale": is_stale, + "last_updated": last_updated, + "rules_version": metadata.get("rules_version"), + "recommendation": recommendation + } + except Exception as e: + logger.warning(f"Failed to parse cache timestamp: {e}") + return { + "exists": True, + "age_seconds": None, + "is_fresh": False, + "is_stale": True, + "recommendation": "Cache timestamp invalid, run validation" + } + + def get_analysis_results(self) -> Optional[Dict[str, Any]]: + """ + Get cached analysis results. + + Returns: + Analysis results dict or None if not cached. + """ + return self._read_cache_file(ANALYSIS_CACHE_FILE) + + def get_violations(self, file_path: Optional[str] = None) -> List[Dict[str, Any]]: + """ + Get cached violations, optionally filtered by file. + + Args: + file_path: Optional file path to filter violations. + + Returns: + List of violation dicts. + """ + violations = self._read_cache_file(VIOLATIONS_CACHE_FILE) + if not violations: + return [] + + violation_list = violations.get("violations", []) + + if file_path: + # Normalize path for comparison + norm_path = str(Path(file_path).resolve()) + return [v for v in violation_list if v.get("file", "").endswith(file_path) or norm_path in v.get("file", "")] + + return violation_list + + def get_rules(self) -> Optional[Dict[str, Any]]: + """ + Get cached rule definitions. + + Returns: + Rules dict or None if not cached. + """ + return self._read_cache_file(RULES_CACHE_FILE) + + def save_analysis_results(self, results: Dict[str, Any]) -> bool: + """ + Save analysis results to cache. + + Args: + results: Analysis results from validation. + + Returns: + True if saved successfully. + """ + success = self._write_cache_file(ANALYSIS_CACHE_FILE, results) + if success: + self._update_metadata({"last_analysis": datetime.now(timezone.utc).isoformat()}) + return success + + def save_violations(self, violations: List[Dict[str, Any]], metadata: Optional[Dict[str, Any]] = None) -> bool: + """ + Save violations to cache. + + Args: + violations: List of violation dicts. + metadata: Optional metadata (rules_version, commit, etc.) + + Returns: + True if saved successfully. + """ + data = { + "violations": violations, + "count": len(violations), + "saved_at": datetime.now(timezone.utc).isoformat(), + **(metadata or {}) + } + success = self._write_cache_file(VIOLATIONS_CACHE_FILE, data) + if success: + self._update_metadata({ + "last_updated": datetime.now(timezone.utc).isoformat(), + "violation_count": len(violations), + "rules_version": metadata.get("rules_version") if metadata else None + }) + return success + + def save_rules(self, rules: Dict[str, Any], version: str) -> bool: + """ + Save rule definitions to cache. + + Args: + rules: Rule definitions dict. + version: Rules package version. + + Returns: + True if saved successfully. + """ + data = { + "rules": rules, + "version": version, + "cached_at": datetime.now(timezone.utc).isoformat() + } + success = self._write_cache_file(RULES_CACHE_FILE, data) + if success: + self._update_metadata({"rules_version": version}) + return success + + def clear_cache(self) -> bool: + """ + Clear all cached data. + + Returns: + True if cleared successfully. + """ + try: + for file in [ANALYSIS_CACHE_FILE, VIOLATIONS_CACHE_FILE, RULES_CACHE_FILE]: + cache_file = self.cache_dir / file + if cache_file.exists(): + cache_file.unlink() + + # Reset metadata + self._write_metadata({"cleared_at": datetime.now(timezone.utc).isoformat()}) + logger.info("Cache cleared") + return True + except Exception as e: + logger.error(f"Failed to clear cache: {e}") + return False + + def _read_cache_file(self, filename: str) -> Optional[Dict[str, Any]]: + """Read a cache file.""" + cache_file = self.cache_dir / filename + if not cache_file.exists(): + return None + + try: + return json.loads(cache_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, Exception) as e: + logger.warning(f"Failed to read cache file {filename}: {e}") + return None + + def _write_cache_file(self, filename: str, data: Dict[str, Any]) -> bool: + """Write a cache file.""" + cache_file = self.cache_dir / filename + try: + cache_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + return True + except Exception as e: + logger.error(f"Failed to write cache file {filename}: {e}") + return False + + def _read_metadata(self) -> Optional[Dict[str, Any]]: + """Read metadata file.""" + metadata_file = self.dss_dir / METADATA_FILE + if not metadata_file.exists(): + return None + + try: + return json.loads(metadata_file.read_text(encoding="utf-8")) + except Exception: + return None + + def _write_metadata(self, data: Dict[str, Any]) -> bool: + """Write metadata file.""" + metadata_file = self.dss_dir / METADATA_FILE + try: + metadata_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + return True + except Exception as e: + logger.error(f"Failed to write metadata: {e}") + return False + + def _update_metadata(self, updates: Dict[str, Any]) -> bool: + """Update metadata file with new values.""" + existing = self._read_metadata() or {} + existing.update(updates) + return self._write_metadata(existing) + + +class LocalCacheValidator: + """ + Validator that uses local cache for offline-capable feedback. + + Used in LOCAL mode to provide fast, advisory validation without + requiring network access to the dashboard. + """ + + def __init__(self, cache: LocalAnalysisCache): + """ + Initialize validator with cache. + + Args: + cache: LocalAnalysisCache instance. + """ + self.cache = cache + + def validate_file(self, file_path: str) -> Dict[str, Any]: + """ + Validate a single file using cached violations. + + Args: + file_path: Path to file to validate. + + Returns: + Validation result dict. + """ + cache_status = self.cache.get_cache_status() + violations = self.cache.get_violations(file_path) + + result = { + "file": file_path, + "violations": violations, + "error_count": len([v for v in violations if v.get("severity") == "error"]), + "warning_count": len([v for v in violations if v.get("severity") == "warning"]), + "cache_status": cache_status, + "mode": "local_cache" + } + + if cache_status.get("is_stale"): + result["warning"] = cache_status["recommendation"] + + return result + + def get_file_status(self, file_path: str) -> str: + """ + Get simple status for a file. + + Returns: + 'pass', 'fail', or 'unknown'. + """ + violations = self.cache.get_violations(file_path) + errors = [v for v in violations if v.get("severity") == "error"] + + if not violations: + # No cached data for this file + return "unknown" + + return "fail" if errors else "pass" + + def get_summary(self) -> Dict[str, Any]: + """ + Get summary of cached validation state. + + Returns: + Summary dict with counts and status. + """ + cache_status = self.cache.get_cache_status() + analysis = self.cache.get_analysis_results() + all_violations = self.cache.get_violations() + + errors = [v for v in all_violations if v.get("severity") == "error"] + warnings = [v for v in all_violations if v.get("severity") == "warning"] + + return { + "cache_status": cache_status, + "total_violations": len(all_violations), + "error_count": len(errors), + "warning_count": len(warnings), + "rules_version": cache_status.get("rules_version"), + "last_updated": cache_status.get("last_updated"), + "analysis": analysis + } + + +def get_project_cache(project_path: Optional[str] = None) -> LocalAnalysisCache: + """ + Factory function to get cache for a project. + + Args: + project_path: Path to project root. + + Returns: + LocalAnalysisCache instance. + """ + return LocalAnalysisCache(project_path) diff --git a/dss-claude-plugin/hooks/.state/.git-backup.lock b/dss-claude-plugin/hooks/.state/.git-backup.lock index 179bfb9..c521251 100644 --- a/dss-claude-plugin/hooks/.state/.git-backup.lock +++ b/dss-claude-plugin/hooks/.state/.git-backup.lock @@ -1 +1 @@ -1765446683593 +1765455715015 \ No newline at end of file diff --git a/packages/dss-rules/bin/cli.js b/packages/dss-rules/bin/cli.js new file mode 100644 index 0000000..4a7fd60 --- /dev/null +++ b/packages/dss-rules/bin/cli.js @@ -0,0 +1,361 @@ +#!/usr/bin/env node +/** + * DSS Rules CLI + * + * Command-line tool for validating files against DSS design system rules. + * Used by CI pipelines, pre-commit hooks, and local development. + */ + +const fs = require('fs'); +const path = require('path'); +const { glob } = require('glob'); +const rules = require('../lib/index'); + +// ANSI colors +const c = { + red: '\x1b[31m', + yellow: '\x1b[33m', + green: '\x1b[32m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + dim: '\x1b[2m', + reset: '\x1b[0m', + bold: '\x1b[1m' +}; + +/** + * Parse command line arguments + */ +function parseArgs(args) { + const options = { + files: [], + json: false, + baseline: null, + strict: false, + quiet: false, + help: false, + selfTest: false, + version: false, + ciMode: false, + fetchBaseline: null + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--json') options.json = true; + else if (arg === '--strict') options.strict = true; + else if (arg === '--quiet' || arg === '-q') options.quiet = true; + else if (arg === '--help' || arg === '-h') options.help = true; + else if (arg === '--self-test') options.selfTest = true; + else if (arg === '--version' || arg === '-v') options.version = true; + else if (arg === '--ci') options.ciMode = true; + else if (arg === '--baseline' && args[i + 1]) { + options.baseline = args[++i]; + } + else if (arg === '--fetch-baseline' && args[i + 1]) { + options.fetchBaseline = args[++i]; + } + else if (!arg.startsWith('-')) { + options.files.push(arg); + } + } + + return options; +} + +/** + * Print help message + */ +function printHelp() { + console.log(` +${c.bold}@dss/rules${c.reset} - Design System Rules Validator + +${c.bold}Usage:${c.reset} + dss-rules [options] + +${c.bold}Commands:${c.reset} + init Initialize DSS in a new project + validate Validate files against design system rules (default) + +${c.bold}Validate Options:${c.reset} + -h, --help Show this help message + -v, --version Show version + --json Output results as JSON + --quiet, -q Minimal output (errors only) + --strict Treat warnings as errors + --ci CI mode (auto-detects baseline, version drift warnings) + --baseline Compare against baseline JSON to show only new violations + --fetch-baseline Fetch baseline from dashboard API + --self-test Verify rules package installation + +${c.bold}Init Options:${c.reset} + --force, -f Overwrite existing configuration + --ci Set up CI workflow (gitea, github, gitlab) + --yes, -y Skip interactive prompts, use defaults + +${c.bold}Examples:${c.reset} + dss-rules init # Initialize new project + dss-rules init --ci github # Initialize with GitHub Actions + dss-rules validate src/**/*.tsx # Validate files + dss-rules --ci --strict src/ # CI mode validation + dss-rules --json src/ > report.json # JSON output + +${c.bold}Ignore Comments:${c.reset} + // dss-ignore Ignore current line + // dss-ignore-next-line Ignore next line + /* dss-ignore */ Block ignore (CSS) + +${c.bold}Skip CI Validation:${c.reset} + git commit -m "fix: hotfix [dss-skip]" + +${c.bold}Exit Codes:${c.reset} + 0 All checks passed + 1 Validation errors found + 2 Configuration error +`); +} + +/** + * Print version info + */ +function printVersion() { + console.log(`@dss/rules v${rules.getVersion()}`); + const config = rules.getCIConfig(); + console.log(` ${config.blockingRules.length} blocking rules`); + console.log(` ${config.advisoryRules.length} advisory rules`); +} + +/** + * Run self-test + */ +function selfTest() { + console.log(`${c.bold}Running @dss/rules self-test...${c.reset}\n`); + + const allRules = rules.loadRules(); + let passed = true; + let totalRules = 0; + + for (const [category, ruleSet] of Object.entries(allRules)) { + if (!ruleSet) { + console.log(`${c.red}āœ—${c.reset} ${category}: Failed to load`); + passed = false; + continue; + } + const count = ruleSet.rules?.length || 0; + totalRules += count; + console.log(`${c.green}āœ“${c.reset} ${category}: ${count} rules (v${ruleSet.version})`); + } + + const config = rules.getCIConfig(); + console.log(`\n${c.bold}Summary:${c.reset}`); + console.log(` Package version: ${config.version}`); + console.log(` Total rules: ${totalRules}`); + console.log(` Blocking (error): ${config.blockingRules.length}`); + console.log(` Advisory (warning): ${config.advisoryRules.length}`); + + if (passed) { + console.log(`\n${c.green}${c.bold}Self-test passed!${c.reset}`); + process.exit(0); + } else { + console.log(`\n${c.red}${c.bold}Self-test failed!${c.reset}`); + process.exit(2); + } +} + +/** + * Expand glob patterns to file list + */ +async function expandGlobs(patterns) { + const files = []; + for (const pattern of patterns) { + if (pattern.includes('*')) { + const matches = await glob(pattern, { nodir: true }); + files.push(...matches); + } else if (fs.existsSync(pattern)) { + const stat = fs.statSync(pattern); + if (stat.isDirectory()) { + const dirFiles = await glob(`${pattern}/**/*.{js,jsx,ts,tsx,css,scss,vue,svelte}`, { nodir: true }); + files.push(...dirFiles); + } else { + files.push(pattern); + } + } + } + return [...new Set(files)]; +} + +/** + * Print validation results + */ +function printResults(results, options) { + if (!options.quiet) { + console.log(`\n${c.bold}=== DSS Rules Validation ===${c.reset}`); + console.log(`${c.dim}Rules version: ${results.rulesVersion}${c.reset}\n`); + } + + for (const fileResult of results.fileResults) { + if (fileResult.errors.length === 0 && fileResult.warnings.length === 0) { + if (!options.quiet) { + console.log(`${c.green}āœ“${c.reset} ${fileResult.file}`); + } + continue; + } + + const icon = fileResult.passed ? c.yellow + '⚠' : c.red + 'āœ—'; + console.log(`${icon}${c.reset} ${fileResult.file}`); + + for (const error of fileResult.errors) { + console.log(` ${c.red}ERROR${c.reset} [${error.rule}] ${error.line}:${error.column}`); + console.log(` ${error.message}`); + console.log(` ${c.dim}Found: ${c.yellow}${error.match}${c.reset}`); + } + + for (const warning of fileResult.warnings) { + if (!options.quiet) { + console.log(` ${c.yellow}WARN${c.reset} [${warning.rule}] ${warning.line}:${warning.column}`); + console.log(` ${warning.message}`); + } + } + + if (fileResult.ignored.length > 0 && !options.quiet) { + console.log(` ${c.dim}(${fileResult.ignored.length} ignored)${c.reset}`); + } + } + + // Summary + console.log(`\n${c.bold}=== Summary ===${c.reset}`); + console.log(`Files: ${results.passedFiles}/${results.totalFiles} passed`); + console.log(`Errors: ${c.red}${results.totalErrors}${c.reset}`); + console.log(`Warnings: ${c.yellow}${results.totalWarnings}${c.reset}`); + if (results.totalIgnored > 0) { + console.log(`Ignored: ${c.dim}${results.totalIgnored}${c.reset}`); + } + + // New violations if baseline comparison + if (results.newErrors !== undefined) { + console.log(`\n${c.bold}New violations:${c.reset} ${results.newErrors.length} errors, ${results.newWarnings.length} warnings`); + console.log(`${c.dim}Existing:${c.reset} ${results.existingErrors.length} errors, ${results.existingWarnings.length} warnings`); + } + + // Final status + if (results.totalErrors > 0) { + console.log(`\n${c.red}${c.bold}Validation failed!${c.reset}`); + } else if (results.totalWarnings > 0 && options.strict) { + console.log(`\n${c.yellow}${c.bold}Validation failed (strict mode)!${c.reset}`); + } else { + console.log(`\n${c.green}${c.bold}Validation passed!${c.reset}`); + } +} + +/** + * Check for version drift and warn + */ +async function checkVersionDrift(options) { + if (!options.ciMode) return; + + try { + // Check if there's a newer version available + const currentVersion = rules.getVersion(); + // In real implementation, would check npm registry + // For now, just show the current version + console.log(`${c.cyan}[CI]${c.reset} Using @dss/rules v${currentVersion}`); + } catch (e) { + // Ignore version check errors + } +} + +/** + * Load baseline for comparison + */ +function loadBaseline(baselinePath) { + if (!baselinePath) return null; + + try { + if (fs.existsSync(baselinePath)) { + return JSON.parse(fs.readFileSync(baselinePath, 'utf-8')); + } + } catch (e) { + console.error(`${c.yellow}Warning: Could not load baseline: ${e.message}${c.reset}`); + } + return null; +} + +/** + * Main entry point + */ +async function main() { + const args = process.argv.slice(2); + + // Check for init command first + if (args[0] === 'init') { + // Delegate to init script + require('./init'); + return; + } + + // Check for validate command (default) + const validateArgs = args[0] === 'validate' ? args.slice(1) : args; + const options = parseArgs(validateArgs); + + if (options.help) { + printHelp(); + process.exit(0); + } + + if (options.version) { + printVersion(); + process.exit(0); + } + + if (options.selfTest) { + selfTest(); + return; + } + + if (options.files.length === 0) { + console.error(`${c.red}Error: No files specified${c.reset}`); + console.log('Run with --help for usage information'); + process.exit(2); + } + + // Check version drift in CI mode + await checkVersionDrift(options); + + // Expand globs + const files = await expandGlobs(options.files); + + if (files.length === 0) { + console.error(`${c.yellow}Warning: No files matched the patterns${c.reset}`); + process.exit(0); + } + + // Run validation + let results = rules.validateFiles(files); + + // Compare with baseline if provided + const baseline = loadBaseline(options.baseline); + if (baseline) { + results = rules.compareWithBaseline(results, baseline); + } + + // Output + if (options.json) { + console.log(JSON.stringify(results, null, 2)); + } else { + printResults(results, options); + } + + // Exit code + if (results.totalErrors > 0) { + process.exit(1); + } + if (options.strict && results.totalWarnings > 0) { + process.exit(1); + } + process.exit(0); +} + +main().catch(err => { + console.error(`${c.red}Error: ${err.message}${c.reset}`); + process.exit(2); +}); diff --git a/packages/dss-rules/bin/init.js b/packages/dss-rules/bin/init.js new file mode 100644 index 0000000..3fa6594 --- /dev/null +++ b/packages/dss-rules/bin/init.js @@ -0,0 +1,489 @@ +#!/usr/bin/env node +/** + * DSS Project Initialization CLI + * + * Sets up a new project for DSS validation: + * - Creates ds.config.json + * - Sets up .dss/ folder with .gitignore + * - Configures package.json scripts + * - Optionally sets up CI workflow + * + * Usage: + * npx @dss/rules init + * npx @dss/rules init --ci gitea + * npx @dss/rules init --force + */ + +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); + +const PACKAGE_VERSION = require('../package.json').version; + +// Template paths +const TEMPLATES_DIR = path.join(__dirname, '..', 'templates'); + +// Default config template +const DEFAULT_CONFIG = { + "$schema": "https://dss.overbits.luz.uy/schemas/ds.config.json", + "project": { + "id": "", + "name": "", + "description": "Design system validation for this project" + }, + "extends": { + "skin": "classic" + }, + "validation": { + "rules": ["colors", "spacing", "typography", "components", "accessibility"], + "severity": { + "colors": "error", + "spacing": "warning", + "typography": "warning", + "components": "error", + "accessibility": "warning" + } + }, + "overrides": { + "tokens": {} + } +}; + +// CI platform configurations +const CI_PLATFORMS = { + gitea: { + name: 'Gitea Actions', + template: 'gitea-workflow.yml', + destDir: '.gitea/workflows', + destFile: 'dss-validate.yml' + }, + github: { + name: 'GitHub Actions', + template: 'github-workflow.yml', + destDir: '.github/workflows', + destFile: 'dss-validate.yml' + }, + gitlab: { + name: 'GitLab CI', + template: 'gitlab-ci.yml', + destDir: '', + destFile: '.gitlab-ci.yml' + } +}; + +async function main() { + const args = process.argv.slice(2); + const options = parseArgs(args); + + console.log('\nšŸŽØ DSS Project Initialization\n'); + console.log(`Version: ${PACKAGE_VERSION}`); + console.log('─'.repeat(40) + '\n'); + + const projectRoot = process.cwd(); + + // Check if already initialized + const configPath = path.join(projectRoot, 'ds.config.json'); + if (fs.existsSync(configPath) && !options.force) { + console.log('āš ļø Project already initialized (ds.config.json exists)'); + console.log(' Use --force to reinitialize\n'); + process.exit(1); + } + + // Interactive mode if not all options provided + const config = options.interactive + ? await interactiveSetup(projectRoot) + : await autoSetup(projectRoot, options); + + // 1. Create ds.config.json + console.log('šŸ“ Creating ds.config.json...'); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + console.log(' āœ“ Configuration file created\n'); + + // 2. Create .dss/ folder structure + console.log('šŸ“ Setting up .dss/ folder...'); + setupDssFolder(projectRoot); + console.log(' āœ“ Local cache folder ready\n'); + + // 3. Update .gitignore + console.log('šŸ“‹ Updating .gitignore...'); + updateGitignore(projectRoot); + console.log(' āœ“ .gitignore updated\n'); + + // 4. Add npm scripts + console.log('šŸ“¦ Adding npm scripts...'); + addNpmScripts(projectRoot); + console.log(' āœ“ Package.json updated\n'); + + // 5. Setup CI if requested + if (options.ci) { + console.log(`šŸ”§ Setting up ${CI_PLATFORMS[options.ci]?.name || options.ci} CI...`); + setupCI(projectRoot, options.ci); + console.log(' āœ“ CI workflow created\n'); + } + + // Success message + console.log('─'.repeat(40)); + console.log('\nāœ… DSS initialization complete!\n'); + console.log('Next steps:'); + console.log(' 1. Review ds.config.json and customize rules'); + console.log(' 2. Run: npx dss-rules validate'); + console.log(' 3. Fix any violations found\n'); + + if (!options.ci) { + console.log('šŸ’” Tip: Set up CI validation with:'); + console.log(' npx @dss/rules init --ci gitea\n'); + } +} + +function parseArgs(args) { + const options = { + force: false, + ci: null, + interactive: true, + projectId: null, + projectName: null + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--force' || arg === '-f') { + options.force = true; + } else if (arg === '--ci') { + options.ci = args[++i] || 'gitea'; + } else if (arg === '--yes' || arg === '-y') { + options.interactive = false; + } else if (arg === '--id') { + options.projectId = args[++i]; + } else if (arg === '--name') { + options.projectName = args[++i]; + } else if (arg === '--help' || arg === '-h') { + showHelp(); + process.exit(0); + } + } + + return options; +} + +function showHelp() { + console.log(` +DSS Project Initialization + +Usage: + npx @dss/rules init [options] + +Options: + --force, -f Overwrite existing configuration + --ci Set up CI workflow (gitea, github, gitlab) + --yes, -y Skip interactive prompts, use defaults + --id Project ID (default: directory name) + --name Project display name + --help, -h Show this help message + +Examples: + npx @dss/rules init + npx @dss/rules init --ci gitea + npx @dss/rules init -y --ci github + npx @dss/rules init --id my-app --name "My Application" +`); +} + +async function interactiveSetup(projectRoot) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + const question = (prompt) => new Promise(resolve => rl.question(prompt, resolve)); + + try { + const dirName = path.basename(projectRoot); + + const projectId = await question(`Project ID [${dirName}]: `) || dirName; + const projectName = await question(`Project Name [${projectId}]: `) || projectId; + const skin = await question('Base skin [classic]: ') || 'classic'; + + rl.close(); + + const config = { ...DEFAULT_CONFIG }; + config.project.id = projectId; + config.project.name = projectName; + config.extends.skin = skin; + + return config; + } catch (e) { + rl.close(); + throw e; + } +} + +async function autoSetup(projectRoot, options) { + const dirName = path.basename(projectRoot); + + const config = { ...DEFAULT_CONFIG }; + config.project.id = options.projectId || dirName; + config.project.name = options.projectName || config.project.id; + + return config; +} + +function setupDssFolder(projectRoot) { + const dssDir = path.join(projectRoot, '.dss'); + const cacheDir = path.join(dssDir, 'cache'); + + // Create directories + if (!fs.existsSync(dssDir)) { + fs.mkdirSync(dssDir, { recursive: true }); + } + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + // Create .gitignore in .dss/ + const gitignorePath = path.join(dssDir, '.gitignore'); + const gitignoreContent = `# DSS local cache - do not commit +* +!.gitignore +`; + fs.writeFileSync(gitignorePath, gitignoreContent); + + // Create metadata.json + const metadataPath = path.join(dssDir, 'metadata.json'); + const metadata = { + initialized_at: new Date().toISOString(), + rules_version: PACKAGE_VERSION, + last_updated: null + }; + fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +} + +function updateGitignore(projectRoot) { + const gitignorePath = path.join(projectRoot, '.gitignore'); + + // Entries to add + const entries = [ + '', + '# DSS local analysis cache', + '.dss/', + '!.dss/.gitignore' + ]; + + let content = ''; + if (fs.existsSync(gitignorePath)) { + content = fs.readFileSync(gitignorePath, 'utf-8'); + + // Check if already configured + if (content.includes('.dss/')) { + console.log(' (already configured)'); + return; + } + } + + // Append entries + const newContent = content + entries.join('\n') + '\n'; + fs.writeFileSync(gitignorePath, newContent); +} + +function addNpmScripts(projectRoot) { + const packagePath = path.join(projectRoot, 'package.json'); + + if (!fs.existsSync(packagePath)) { + console.log(' (no package.json found, skipping)'); + return; + } + + try { + const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf-8')); + + // Add scripts + pkg.scripts = pkg.scripts || {}; + + if (!pkg.scripts['dss:validate']) { + pkg.scripts['dss:validate'] = 'dss-rules validate'; + } + if (!pkg.scripts['dss:validate:ci']) { + pkg.scripts['dss:validate:ci'] = 'dss-rules validate --ci --strict --json > .dss/results.json'; + } + if (!pkg.scripts['dss:baseline']) { + pkg.scripts['dss:baseline'] = 'dss-rules validate --baseline'; + } + + fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n'); + } catch (e) { + console.log(` āš ļø Failed to update package.json: ${e.message}`); + } +} + +function setupCI(projectRoot, platform) { + const ciConfig = CI_PLATFORMS[platform]; + + if (!ciConfig) { + console.log(` āš ļø Unknown CI platform: ${platform}`); + console.log(` Supported: ${Object.keys(CI_PLATFORMS).join(', ')}`); + return; + } + + // Read template + const templatePath = path.join(TEMPLATES_DIR, ciConfig.template); + if (!fs.existsSync(templatePath)) { + // Create a default template inline + const template = getDefaultCITemplate(platform); + writeCIFile(projectRoot, ciConfig, template); + return; + } + + const template = fs.readFileSync(templatePath, 'utf-8'); + writeCIFile(projectRoot, ciConfig, template); +} + +function writeCIFile(projectRoot, ciConfig, content) { + const destDir = path.join(projectRoot, ciConfig.destDir); + const destPath = path.join(destDir, ciConfig.destFile); + + // Create directory + if (ciConfig.destDir && !fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + fs.writeFileSync(destPath, content); +} + +function getDefaultCITemplate(platform) { + if (platform === 'github') { + return `# DSS Design System Validation +name: DSS Validate + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check for [dss-skip] + id: skip-check + run: | + if git log -1 --pretty=%B | grep -q '\\[dss-skip\\]'; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "āš ļø DSS validation skipped via [dss-skip] commit message" + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - name: Run DSS validation + if: steps.skip-check.outputs.skip != 'true' + run: npm run dss:validate:ci + + - name: Upload metrics to dashboard + if: steps.skip-check.outputs.skip != 'true' + run: | + curl -X POST "\${DSS_DASHBOARD_URL}/api/metrics/upload" \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer \${DSS_API_TOKEN}" \\ + -d @.dss/results.json + env: + DSS_DASHBOARD_URL: \${{ secrets.DSS_DASHBOARD_URL }} + DSS_API_TOKEN: \${{ secrets.DSS_API_TOKEN }} +`; + } + + if (platform === 'gitlab') { + return `# DSS Design System Validation +stages: + - validate + +dss-validate: + stage: validate + image: node:20 + script: + - npm ci + - | + if git log -1 --pretty=%B | grep -q '\\[dss-skip\\]'; then + echo "āš ļø DSS validation skipped via [dss-skip] commit message" + exit 0 + fi + - npm run dss:validate:ci + - | + curl -X POST "\${DSS_DASHBOARD_URL}/api/metrics/upload" \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer \${DSS_API_TOKEN}" \\ + -d @.dss/results.json + only: + - main + - master + - develop + - merge_requests +`; + } + + // Default to gitea template (most similar to the one in templates/) + return `# DSS Design System Validation +name: DSS Validate + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci + + - name: Check for [dss-skip] + id: skip-check + run: | + if git log -1 --pretty=%B | grep -q '\\[dss-skip\\]'; then + echo "skip=true" >> \$GITHUB_OUTPUT + echo "::warning::DSS validation skipped via [dss-skip] commit message" + else + echo "skip=false" >> \$GITHUB_OUTPUT + fi + + - name: Run DSS validation + if: steps.skip-check.outputs.skip != 'true' + run: npm run dss:validate:ci + + - name: Upload metrics to dashboard + if: steps.skip-check.outputs.skip != 'true' && always() + run: | + curl -X POST "\${DSS_DASHBOARD_URL}/api/metrics/upload" \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer \${DSS_API_TOKEN}" \\ + -d @.dss/results.json + env: + DSS_DASHBOARD_URL: \${{ secrets.DSS_DASHBOARD_URL }} + DSS_API_TOKEN: \${{ secrets.DSS_API_TOKEN }} +`; +} + +// Run main +main().catch(err => { + console.error('\nāŒ Initialization failed:', err.message); + process.exit(1); +}); diff --git a/packages/dss-rules/lib/index.js b/packages/dss-rules/lib/index.js index b6ead09..e74d6e9 100644 --- a/packages/dss-rules/lib/index.js +++ b/packages/dss-rules/lib/index.js @@ -1,19 +1,23 @@ /** * @dss/rules - Design System Rules Package * - * Provides versioned rule definitions for enterprise design system enforcement. - * Pull-based distribution via npm for consistent rule versions across 60+ projects. + * Versioned rule definitions for enterprise design system enforcement. + * Pull-based distribution via npm for consistent rule versions across projects. */ const fs = require('fs'); const path = require('path'); -// Rule categories const CATEGORIES = ['colors', 'spacing', 'typography', 'components', 'accessibility']; +// Match dss-ignore in various comment styles +// - // dss-ignore (JS/TS line comment) +// - /* dss-ignore */ (CSS/JS block comment) +// - # dss-ignore (Python/YAML/Shell comment) +const IGNORE_PATTERN = /\/\/\s*dss-ignore(-next-line)?|\/\*\s*dss-ignore(-next-line)?\s*\*\/|#\s*dss-ignore(-next-line)?/; +const SKIP_COMMIT_PATTERN = /\[dss-skip\]/; /** * Load all rules from the rules directory - * @returns {Object} Rules organized by category */ function loadRules() { const rules = {}; @@ -23,22 +27,18 @@ function loadRules() { const rulePath = path.join(rulesDir, `${category}.json`); if (fs.existsSync(rulePath)) { try { - const content = fs.readFileSync(rulePath, 'utf-8'); - rules[category] = JSON.parse(content); + rules[category] = JSON.parse(fs.readFileSync(rulePath, 'utf-8')); } catch (error) { console.error(`Failed to load rules for ${category}:`, error.message); rules[category] = null; } } } - return rules; } /** * Get rules for a specific category - * @param {string} category - Rule category (colors, spacing, etc.) - * @returns {Object|null} Rule definitions or null if not found */ function getRulesByCategory(category) { const rules = loadRules(); @@ -47,112 +47,244 @@ function getRulesByCategory(category) { /** * Get all rule IDs across all categories - * @returns {string[]} Array of rule IDs in format "category/rule-id" */ function getAllRuleIds() { const rules = loadRules(); const ids = []; - for (const [category, ruleSet] of Object.entries(rules)) { - if (ruleSet && ruleSet.rules) { + if (ruleSet?.rules) { for (const rule of ruleSet.rules) { ids.push(`${category}/${rule.id}`); } } } - return ids; } /** - * Get a specific rule by its full ID - * @param {string} ruleId - Full rule ID in format "category/rule-id" - * @returns {Object|null} Rule definition or null + * Get a specific rule by full ID (category/rule-id) */ function getRule(ruleId) { const [category, id] = ruleId.split('/'); const ruleSet = getRulesByCategory(category); - - if (!ruleSet || !ruleSet.rules) return null; - + if (!ruleSet?.rules) return null; return ruleSet.rules.find(r => r.id === id) || null; } /** - * Validate a value against rule patterns - * @param {string} ruleId - Full rule ID - * @param {string} value - Value to validate - * @returns {Object} Validation result {valid, violations} - */ -function validateValue(ruleId, value) { - const rule = getRule(ruleId); - if (!rule) { - return { valid: true, violations: [], error: `Rule not found: ${ruleId}` }; - } - - const violations = []; - - // Check forbidden patterns - if (rule.patterns?.forbidden) { - for (const pattern of rule.patterns.forbidden) { - const regex = new RegExp(pattern, 'gi'); - const matches = value.match(regex); - if (matches) { - violations.push({ - rule: ruleId, - pattern, - matches, - severity: rule.severity || 'warning', - message: `Found forbidden pattern: ${matches.join(', ')}` - }); - } - } - } - - return { - valid: violations.length === 0, - violations - }; -} - -/** - * Get required tokens from all rule sets - * @returns {Object} Required tokens organized by category - */ -function getRequiredTokens() { - const rules = loadRules(); - const required = {}; - - for (const [category, ruleSet] of Object.entries(rules)) { - if (ruleSet?.tokens?.required) { - required[category] = ruleSet.tokens.required; - } - } - - return required; -} - -/** - * Get severity level for a rule - * @param {string} ruleId - Full rule ID - * @returns {string} Severity level (error, warning, info) + * Get rule severity */ function getRuleSeverity(ruleId) { const rule = getRule(ruleId); if (!rule) return 'warning'; - - // Rule-specific severity overrides category default if (rule.severity) return rule.severity; - - // Fall back to category default const [category] = ruleId.split('/'); const ruleSet = getRulesByCategory(category); return ruleSet?.severity || 'warning'; } +/** + * Check if a line has dss-ignore comment + */ +function isLineIgnored(lines, lineNumber) { + if (lineNumber <= 0 || lineNumber > lines.length) return false; + + const currentLine = lines[lineNumber - 1]; + const previousLine = lineNumber > 1 ? lines[lineNumber - 2] : ''; + + // Check current line for inline ignore (on same line as violation) + if (IGNORE_PATTERN.test(currentLine)) return true; + + // Check previous line for dss-ignore-next-line OR standalone dss-ignore + // A standalone /* dss-ignore */ on its own line ignores the next line + if (/dss-ignore-next-line/.test(previousLine)) return true; + + // Check if previous line is ONLY a dss-ignore comment (standalone) + // This handles: /* dss-ignore */ on its own line + const standaloneIgnore = /^\s*(\/\*\s*dss-ignore\s*\*\/|\/\/\s*dss-ignore|#\s*dss-ignore)\s*$/; + if (standaloneIgnore.test(previousLine)) return true; + + return false; +} + +/** + * Validate file content against rules with dss-ignore support + */ +function validateContent(content, filePath, options = {}) { + const results = { + file: filePath, + errors: [], + warnings: [], + info: [], + ignored: [], + passed: true + }; + + const lines = content.split('\n'); + const ext = path.extname(filePath).toLowerCase(); + const applicableCategories = getApplicableCategories(ext); + + for (const category of applicableCategories) { + const ruleSet = getRulesByCategory(category); + if (!ruleSet?.rules) continue; + + for (const rule of ruleSet.rules) { + // Skip if file matches exception patterns + if (rule.exceptions?.some(exc => { + // Handle glob-like patterns more carefully + // *.test.* should only match filenames like "foo.test.js", not paths containing "test" + if (exc.startsWith('**/')) { + // Directory pattern: **/fixtures/** -> match any path containing /fixtures/ + const dirName = exc.replace(/^\*\*\//, '').replace(/\/\*\*$/, ''); + return filePath.includes(`/${dirName}/`); + } else if (exc.includes('/')) { + // Path pattern + const pattern = exc.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*'); + return new RegExp(pattern).test(filePath); + } else if (exc.startsWith('*.') || exc.endsWith('.*')) { + // Filename extension pattern: *.test.* matches only the basename + const basename = path.basename(filePath); + const pattern = '^' + exc.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'; + return new RegExp(pattern).test(basename); + } else { + // Simple value exception (like "transparent", "inherit") + return false; // These are value exceptions, not file exceptions + } + })) continue; + + // Check forbidden patterns + if (rule.patterns?.forbidden) { + for (const pattern of rule.patterns.forbidden) { + try { + const regex = new RegExp(pattern, 'gm'); + let match; + while ((match = regex.exec(content)) !== null) { + const lineNumber = content.substring(0, match.index).split('\n').length; + const column = match.index - content.lastIndexOf('\n', match.index - 1); + + // Check if this line is ignored + if (isLineIgnored(lines, lineNumber)) { + results.ignored.push({ + rule: `${category}/${rule.id}`, + line: lineNumber, + column, + match: match[0] + }); + continue; + } + + const violation = { + rule: `${category}/${rule.id}`, + name: rule.name, + file: filePath, + line: lineNumber, + column, + match: match[0], + message: rule.description || `Violation of ${rule.name}` + }; + + const severity = rule.severity || ruleSet.severity || 'warning'; + if (severity === 'error') { + results.errors.push(violation); + results.passed = false; + } else if (severity === 'warning') { + results.warnings.push(violation); + } else { + results.info.push(violation); + } + } + } catch (e) { + // Invalid regex, skip + } + } + } + } + } + + return results; +} + +/** + * Validate a file from disk + */ +function validateFile(filePath, options = {}) { + if (!fs.existsSync(filePath)) { + return { + file: filePath, + errors: [{ message: `File not found: ${filePath}` }], + warnings: [], + info: [], + ignored: [], + passed: false + }; + } + const content = fs.readFileSync(filePath, 'utf-8'); + return validateContent(content, filePath, options); +} + +/** + * Determine applicable rule categories based on file extension + */ +function getApplicableCategories(ext) { + const cssTypes = ['.css', '.scss', '.sass', '.less', '.styl']; + const jsTypes = ['.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte']; + const htmlTypes = ['.html', '.htm', '.vue', '.svelte', '.jsx', '.tsx']; + + const categories = []; + if (cssTypes.includes(ext)) categories.push('colors', 'spacing', 'typography', 'accessibility'); + if (jsTypes.includes(ext)) categories.push('colors', 'spacing', 'components'); + if (htmlTypes.includes(ext)) categories.push('accessibility', 'components'); + return [...new Set(categories)]; +} + +/** + * Validate multiple files + */ +function validateFiles(files, options = {}) { + const results = { + totalFiles: files.length, + passedFiles: 0, + failedFiles: 0, + totalErrors: 0, + totalWarnings: 0, + totalIgnored: 0, + fileResults: [], + rulesVersion: getVersion() + }; + + for (const file of files) { + const fileResult = validateFile(file, options); + results.fileResults.push(fileResult); + + if (fileResult.passed) { + results.passedFiles++; + } else { + results.failedFiles++; + } + results.totalErrors += fileResult.errors.length; + results.totalWarnings += fileResult.warnings.length; + results.totalIgnored += fileResult.ignored.length; + } + + return results; +} + +/** + * Get required tokens from all rule sets + */ +function getRequiredTokens() { + const rules = loadRules(); + const required = {}; + for (const [category, ruleSet] of Object.entries(rules)) { + if (ruleSet?.tokens?.required) { + required[category] = ruleSet.tokens.required; + } + } + return required; +} + /** * Get package version - * @returns {string} Package version */ function getVersion() { const packagePath = path.join(__dirname, '..', 'package.json'); @@ -161,38 +293,95 @@ function getVersion() { } /** - * Export configuration for CI/CD integration - * @returns {Object} Configuration object for CI pipelines + * Check if commit message contains skip flag + */ +function shouldSkipValidation(commitMessage) { + return SKIP_COMMIT_PATTERN.test(commitMessage); +} + +/** + * Get CI configuration */ function getCIConfig() { return { version: getVersion(), categories: CATEGORIES, - errorSeverities: ['error'], - warningSeverities: ['warning'], blockingRules: getAllRuleIds().filter(id => getRuleSeverity(id) === 'error'), - advisoryRules: getAllRuleIds().filter(id => getRuleSeverity(id) !== 'error') + advisoryRules: getAllRuleIds().filter(id => getRuleSeverity(id) !== 'error'), + skipPattern: '[dss-skip]' }; } +/** + * Compare against baseline to find new violations only + */ +function compareWithBaseline(current, baseline) { + if (!baseline) return current; + + const baselineViolations = new Set( + baseline.fileResults?.flatMap(f => + [...f.errors, ...f.warnings].map(v => `${v.file}:${v.rule}:${v.line}`) + ) || [] + ); + + const newResults = { + ...current, + newErrors: [], + newWarnings: [], + existingErrors: [], + existingWarnings: [] + }; + + for (const fileResult of current.fileResults) { + for (const error of fileResult.errors) { + const key = `${error.file}:${error.rule}:${error.line}`; + if (baselineViolations.has(key)) { + newResults.existingErrors.push(error); + } else { + newResults.newErrors.push(error); + } + } + for (const warning of fileResult.warnings) { + const key = `${warning.file}:${warning.rule}:${warning.line}`; + if (baselineViolations.has(key)) { + newResults.existingWarnings.push(warning); + } else { + newResults.newWarnings.push(warning); + } + } + } + + return newResults; +} + module.exports = { // Rule loading loadRules, getRulesByCategory, getAllRuleIds, getRule, + getRuleSeverity, // Validation - validateValue, - getRuleSeverity, + validateContent, + validateFile, + validateFiles, + isLineIgnored, + getApplicableCategories, + + // Baseline comparison + compareWithBaseline, + + // CI helpers + getCIConfig, + shouldSkipValidation, // Token helpers getRequiredTokens, // Metadata getVersion, - getCIConfig, - - // Constants - CATEGORIES + CATEGORIES, + IGNORE_PATTERN, + SKIP_COMMIT_PATTERN }; diff --git a/packages/dss-rules/package.json b/packages/dss-rules/package.json index e08a739..d1b3a10 100644 --- a/packages/dss-rules/package.json +++ b/packages/dss-rules/package.json @@ -1,17 +1,23 @@ { "name": "@dss/rules", "version": "1.0.0", - "description": "DSS Design System Rules - Versioned rule definitions for enterprise design system enforcement", + "description": "DSS Design System Rules - Versioned rule definitions for enterprise enforcement", "main": "lib/index.js", "types": "lib/index.d.ts", + "bin": { + "dss-rules": "bin/cli.js", + "dss-init": "bin/init.js" + }, "files": [ "lib", + "bin", "rules", - "schemas" + "schemas", + "templates" ], "scripts": { "build": "tsc", - "test": "node lib/validate.js --self-test", + "test": "node bin/cli.js --self-test", "prepublishOnly": "npm run build && npm test" }, "keywords": [ @@ -19,16 +25,14 @@ "dss", "rules", "tokens", - "enterprise" + "enterprise", + "linting" ], "author": "DSS Team", "license": "MIT", "devDependencies": { "typescript": "^5.0.0" }, - "peerDependencies": { - "ajv": "^8.0.0" - }, "engines": { "node": ">=18.0.0" }, diff --git a/packages/dss-rules/rules/accessibility.json b/packages/dss-rules/rules/accessibility.json index c76e97d..56f23f0 100644 --- a/packages/dss-rules/rules/accessibility.json +++ b/packages/dss-rules/rules/accessibility.json @@ -3,22 +3,20 @@ "id": "accessibility", "version": "1.0.0", "name": "Accessibility Rules", - "description": "WCAG 2.1 AA compliance rules for accessible design", + "description": "WCAG 2.1 AA compliance rules (token-based, not computed)", "category": "accessibility", "severity": "error", "rules": [ { "id": "images-have-alt", "name": "Images Must Have Alt Text", - "description": "All img elements must have meaningful alt text or be marked decorative", + "description": "All img elements must have alt attribute", "severity": "error", "wcag": "1.1.1", "validation": { "type": "attribute-required", "element": "img", - "attribute": "alt", - "allowEmpty": true, - "emptyMeansDecorative": true + "attribute": "alt" } }, { @@ -29,8 +27,7 @@ "wcag": "4.1.2", "validation": { "type": "accessible-name", - "elements": ["button", "[role=button]"], - "sources": ["text content", "aria-label", "aria-labelledby"] + "elements": ["button", "[role=button]"] } }, { @@ -41,71 +38,38 @@ "wcag": "1.3.1", "validation": { "type": "label-association", - "elements": ["input", "select", "textarea"], - "methods": ["for/id", "aria-labelledby", "aria-label", "wrapper"] + "elements": ["input", "select", "textarea"] } }, { - "id": "focus-visible", - "name": "Focus Must Be Visible", - "description": "Interactive elements must have visible focus indicators", + "id": "no-focus-outline-none", + "name": "Do Not Remove Focus Outline", + "description": "Never use outline: none on focusable elements", "severity": "error", "wcag": "2.4.7", - "validation": { - "type": "focus-style", - "minContrastRatio": 3.0, - "forbiddenPatterns": ["outline: none", "outline: 0", ":focus { outline: none }"] + "patterns": { + "forbidden": [ + "outline:\\s*none", + "outline:\\s*0(?![0-9])", + ":focus\\s*\\{[^}]*outline:\\s*none" + ] } }, - { - "id": "color-not-only", - "name": "Color Not Only Indicator", - "description": "Information must not be conveyed by color alone", - "severity": "warning", - "wcag": "1.4.1", - "guidelines": [ - "Error states need icon + color + text", - "Links in text need underline or other indicator", - "Status indicators need icon or pattern" - ] - }, { "id": "touch-target-size", "name": "Minimum Touch Target Size", - "description": "Interactive elements must be at least 44x44 CSS pixels", + "description": "Interactive elements should be at least 44x44 CSS pixels", "severity": "warning", "wcag": "2.5.5", - "validation": { - "type": "size-check", - "minWidth": 44, - "minHeight": 44, - "elements": ["button", "a", "[role=button]", "input[type=checkbox]", "input[type=radio]"] - } - }, - { - "id": "keyboard-navigation", - "name": "Keyboard Navigation", - "description": "All functionality must be accessible via keyboard", - "severity": "error", - "wcag": "2.1.1", - "validation": { - "type": "keyboard-accessible", - "requirements": [ - "All interactive elements focusable", - "No keyboard traps", - "Logical tab order", - "Skip links for navigation" - ] - } + "guidelines": [ + "Use Button component which ensures minimum size", + "Ensure clickable areas have sufficient padding" + ] } ], "compliance": { "level": "AA", "standards": ["WCAG 2.1"], - "testingTools": [ - "axe-core", - "pa11y", - "lighthouse" - ] + "note": "Computed checks (contrast ratio) require runtime analysis" } } diff --git a/packages/dss-rules/rules/colors.json b/packages/dss-rules/rules/colors.json index 26b55f2..9a30812 100644 --- a/packages/dss-rules/rules/colors.json +++ b/packages/dss-rules/rules/colors.json @@ -10,11 +10,11 @@ { "id": "no-hardcoded-colors", "name": "No Hardcoded Colors", - "description": "All colors must use design tokens, not hardcoded hex/rgb values", + "description": "Colors must use design tokens, not hardcoded hex/rgb values", "severity": "error", "patterns": { "forbidden": [ - "#[0-9a-fA-F]{3,8}", + "#[0-9a-fA-F]{3,8}(?![0-9a-fA-F])", "rgb\\([^)]+\\)", "rgba\\([^)]+\\)", "hsl\\([^)]+\\)", @@ -27,11 +27,7 @@ "theme\\.[a-z]+" ] }, - "exceptions": [ - "*.test.*", - "*.spec.*", - "**/fixtures/**" - ] + "exceptions": ["*.test.*", "*.spec.*", "**/fixtures/**", "transparent", "inherit", "currentColor"] }, { "id": "semantic-color-naming", @@ -56,20 +52,7 @@ } ], "tokens": { - "required": [ - "colors.primary", - "colors.secondary", - "colors.background", - "colors.foreground", - "colors.border", - "colors.error", - "colors.success", - "colors.warning" - ], - "optional": [ - "colors.muted", - "colors.accent", - "colors.info" - ] + "required": ["colors.primary", "colors.secondary", "colors.background", "colors.foreground", "colors.border", "colors.error", "colors.success", "colors.warning"], + "optional": ["colors.muted", "colors.accent", "colors.info"] } } diff --git a/packages/dss-rules/rules/components.json b/packages/dss-rules/rules/components.json index 7b32498..b18b122 100644 --- a/packages/dss-rules/rules/components.json +++ b/packages/dss-rules/rules/components.json @@ -10,7 +10,7 @@ { "id": "use-design-system-components", "name": "Use Design System Components", - "description": "Prefer design system components over custom implementations", + "description": "Prefer design system components over native HTML or custom implementations", "severity": "error", "components": { "required": { @@ -44,17 +44,12 @@ "severity": "error", "validation": { "Button": { - "requiredProps": ["variant", "size"], - "conditionalProps": { - "loading": ["loadingText"], - "icon": ["aria-label"] - } + "requiredProps": ["variant"], + "conditionalProps": { "loading": ["loadingText"], "icon": ["aria-label"] } }, "Input": { "requiredProps": ["label", "name"], - "conditionalProps": { - "error": ["errorMessage"] - } + "conditionalProps": { "error": ["errorMessage"] } }, "Modal": { "requiredProps": ["title", "onClose"], @@ -62,52 +57,15 @@ } } }, - { - "id": "component-composition", - "name": "Component Composition Patterns", - "description": "Follow recommended composition patterns for complex UIs", - "severity": "info", - "patterns": { - "forms": { - "structure": ["Form", "FormField", "Input/Select", "Button"], - "guidelines": [ - "Wrap inputs in FormField for consistent labeling", - "Use Form component for validation handling", - "Place submit button inside Form" - ] - }, - "lists": { - "structure": ["List", "ListItem"], - "guidelines": [ - "Use semantic list components for accessibility", - "Implement virtualization for 50+ items" - ] - }, - "navigation": { - "structure": ["Nav", "NavItem", "NavLink"], - "guidelines": [ - "Use Nav component for main navigation", - "Implement active state handling" - ] - } - } - }, { "id": "no-inline-styles", "name": "No Inline Styles on Components", "description": "Components should use className/variant props, not style attribute", "severity": "warning", "patterns": { - "forbidden": [ - "style={{", - "style={{" - ], - "exceptions": [ - "dynamic positioning", - "animations", - "calculated values" - ] - } + "forbidden": ["style={{", "style={"] + }, + "exceptions": ["dynamic positioning", "animations", "calculated values"] } ], "adoption": { @@ -116,10 +74,6 @@ "target": 80, "excellent": 95 }, - "metrics": [ - "percentage_using_ds_components", - "custom_component_count", - "token_compliance_rate" - ] + "metrics": ["percentage_using_ds_components", "custom_component_count", "token_compliance_rate"] } } diff --git a/packages/dss-rules/rules/spacing.json b/packages/dss-rules/rules/spacing.json index 0baa77e..174184f 100644 --- a/packages/dss-rules/rules/spacing.json +++ b/packages/dss-rules/rules/spacing.json @@ -10,7 +10,7 @@ { "id": "no-arbitrary-spacing", "name": "No Arbitrary Spacing Values", - "description": "Spacing must use token scale (4px increments), not arbitrary values", + "description": "Spacing must use token scale, not arbitrary pixel values", "severity": "warning", "patterns": { "forbidden": [ @@ -24,12 +24,7 @@ "spacing\\.[a-z0-9]+" ] }, - "exceptions": [ - "0", - "0px", - "auto", - "inherit" - ] + "exceptions": ["0", "0px", "auto", "inherit"] }, { "id": "spacing-scale", @@ -40,29 +35,10 @@ "type": "scale-check", "allowedValues": [0, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96, 128] } - }, - { - "id": "consistent-component-spacing", - "name": "Component Internal Spacing", - "description": "Components should use consistent internal spacing patterns", - "severity": "info", - "guidelines": [ - "Use spacing.xs (4px) for tight groupings", - "Use spacing.sm (8px) for related elements", - "Use spacing.md (16px) for section separation", - "Use spacing.lg (24px) for major sections", - "Use spacing.xl (32px+) for page-level separation" - ] } ], "tokens": { - "required": [ - "spacing.xs", - "spacing.sm", - "spacing.md", - "spacing.lg", - "spacing.xl" - ], + "required": ["spacing.xs", "spacing.sm", "spacing.md", "spacing.lg", "spacing.xl"], "scale": { "xs": "4px", "sm": "8px", diff --git a/packages/dss-rules/rules/typography.json b/packages/dss-rules/rules/typography.json index 49f4db6..52fe0fb 100644 --- a/packages/dss-rules/rules/typography.json +++ b/packages/dss-rules/rules/typography.json @@ -10,15 +10,16 @@ { "id": "use-typography-scale", "name": "Use Typography Scale", - "description": "Font sizes must use the defined typography scale", + "description": "Font sizes must use the defined typography scale tokens", "severity": "error", "patterns": { "forbidden": [ "font-size:\\s*[0-9]+px", - "fontSize:\\s*[0-9]+" + "fontSize:\\s*[0-9]+", + "fontSize:\\s*'[0-9]+px'" ], "allowed": [ - "var\\(--font-size-[a-z]+\\)", + "var\\(--font-size-[a-z0-9]+\\)", "\\$font-size-[a-z]+", "typography\\.[a-z]+" ] @@ -36,26 +37,25 @@ } }, { - "id": "line-height-consistency", - "name": "Consistent Line Heights", - "description": "Line heights should match the typography scale", - "severity": "info", - "guidelines": [ - "Use lineHeight.tight (1.25) for headings", - "Use lineHeight.normal (1.5) for body text", - "Use lineHeight.relaxed (1.75) for long-form content" - ] + "id": "no-font-family-override", + "name": "No Font Family Override", + "description": "Font families should use design system tokens", + "severity": "warning", + "patterns": { + "forbidden": [ + "font-family:\\s*['\"][^'\"]+['\"]", + "fontFamily:\\s*['\"][^'\"]+['\"]" + ], + "allowed": [ + "var\\(--font-[a-z]+\\)", + "\\$font-[a-z]+", + "fonts\\.[a-z]+" + ] + } } ], "tokens": { - "required": [ - "typography.h1", - "typography.h2", - "typography.h3", - "typography.body", - "typography.small", - "typography.caption" - ], + "required": ["typography.h1", "typography.h2", "typography.h3", "typography.body", "typography.small"], "scale": { "xs": "12px", "sm": "14px", @@ -64,12 +64,7 @@ "xl": "20px", "2xl": "24px", "3xl": "30px", - "4xl": "36px", - "5xl": "48px" - }, - "fontFamilies": { - "sans": "Inter, system-ui, sans-serif", - "mono": "JetBrains Mono, monospace" + "4xl": "36px" } } } diff --git a/packages/dss-rules/schemas/ds.config.schema.json b/packages/dss-rules/schemas/ds.config.schema.json new file mode 100644 index 0000000..1c5c5d2 --- /dev/null +++ b/packages/dss-rules/schemas/ds.config.schema.json @@ -0,0 +1,118 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://dss.overbits.luz.uy/schemas/ds.config.schema.json", + "title": "DSS Project Configuration", + "description": "Configuration schema for DSS-enabled projects", + "type": "object", + "required": ["name", "rules"], + "properties": { + "$schema": { + "type": "string" + }, + "name": { + "type": "string", + "description": "Project name" + }, + "version": { + "type": "string", + "description": "Project version" + }, + "rules": { + "type": "object", + "required": ["package"], + "properties": { + "package": { + "type": "string", + "description": "Rules package name (e.g., @dss/rules)" + }, + "version": { + "type": "string", + "description": "Semver version constraint" + }, + "overrides": { + "type": "object", + "description": "Rule-specific overrides", + "additionalProperties": { + "type": "object", + "properties": { + "severity": { + "enum": ["error", "warning", "info", "off"] + }, + "enabled": { + "type": "boolean" + } + } + } + } + } + }, + "analysis": { + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { "type": "string" }, + "description": "Glob patterns for files to analyze" + }, + "exclude": { + "type": "array", + "items": { "type": "string" }, + "description": "Glob patterns for files to exclude" + }, + "output": { + "type": "string", + "description": "Output path for analysis graph" + } + } + }, + "metrics": { + "type": "object", + "properties": { + "upload": { + "type": "boolean", + "description": "Whether to upload metrics to dashboard" + }, + "dashboardUrl": { + "type": "string", + "format": "uri", + "description": "Dashboard API endpoint" + }, + "projectId": { + "type": "string", + "description": "Project identifier in dashboard" + } + } + }, + "ci": { + "type": "object", + "properties": { + "blocking": { + "type": "boolean", + "description": "Whether errors block the pipeline" + }, + "skipPattern": { + "type": "string", + "description": "Pattern in commit message to skip validation" + }, + "baselineBranch": { + "type": "string", + "description": "Branch to compare against for new violations" + } + } + }, + "tokens": { + "type": "object", + "description": "Token configuration", + "properties": { + "source": { + "type": "string", + "description": "Path to token definitions" + }, + "format": { + "enum": ["css", "scss", "json", "js"], + "description": "Token file format" + } + } + } + } +} diff --git a/packages/dss-rules/schemas/rule.schema.json b/packages/dss-rules/schemas/rule.schema.json index cd54067..5300343 100644 --- a/packages/dss-rules/schemas/rule.schema.json +++ b/packages/dss-rules/schemas/rule.schema.json @@ -7,74 +7,44 @@ "required": ["id", "version", "name", "category", "rules"], "properties": { "$schema": { - "type": "string", - "description": "Reference to this schema" + "type": "string" }, "id": { "type": "string", - "pattern": "^[a-z][a-z0-9-]*$", - "description": "Unique identifier for this rule set" + "pattern": "^[a-z][a-z0-9-]*$" }, "version": { "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", - "description": "Semantic version of this rule set" + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, "name": { - "type": "string", - "description": "Human-readable name" + "type": "string" }, "description": { - "type": "string", - "description": "Detailed description of the rule set" + "type": "string" }, "category": { "type": "string", - "enum": ["tokens", "components", "accessibility", "patterns", "naming"], - "description": "Category this rule set belongs to" + "enum": ["tokens", "components", "accessibility", "patterns", "naming"] }, "severity": { "type": "string", "enum": ["error", "warning", "info"], - "default": "warning", - "description": "Default severity for rules in this set" + "default": "warning" }, "rules": { "type": "array", "items": { "$ref": "#/definitions/Rule" - }, - "description": "Individual rules in this set" + } }, "tokens": { "type": "object", - "description": "Token requirements and definitions", "properties": { - "required": { - "type": "array", - "items": { "type": "string" } - }, - "optional": { - "type": "array", - "items": { "type": "string" } - }, - "scale": { - "type": "object", - "additionalProperties": { "type": "string" } - } + "required": { "type": "array", "items": { "type": "string" } }, + "optional": { "type": "array", "items": { "type": "string" } }, + "scale": { "type": "object", "additionalProperties": { "type": "string" } } } - }, - "components": { - "type": "object", - "description": "Component requirements" - }, - "compliance": { - "type": "object", - "description": "Compliance metadata" - }, - "adoption": { - "type": "object", - "description": "Adoption threshold definitions" } }, "definitions": { @@ -82,61 +52,21 @@ "type": "object", "required": ["id", "name"], "properties": { - "id": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$", - "description": "Unique rule identifier" - }, - "name": { - "type": "string", - "description": "Human-readable rule name" - }, - "description": { - "type": "string", - "description": "What this rule checks for" - }, - "severity": { - "type": "string", - "enum": ["error", "warning", "info"], - "description": "Rule severity (overrides set default)" - }, - "wcag": { - "type": "string", - "description": "WCAG criterion reference if applicable" - }, + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "name": { "type": "string" }, + "description": { "type": "string" }, + "severity": { "type": "string", "enum": ["error", "warning", "info"] }, + "wcag": { "type": "string" }, "patterns": { "type": "object", "properties": { - "forbidden": { - "type": "array", - "items": { "type": "string" }, - "description": "Regex patterns that violate this rule" - }, - "allowed": { - "type": "array", - "items": { "type": "string" }, - "description": "Regex patterns that satisfy this rule" - } + "forbidden": { "type": "array", "items": { "type": "string" } }, + "allowed": { "type": "array", "items": { "type": "string" } } } }, - "validation": { - "type": "object", - "description": "Validation configuration" - }, - "exceptions": { - "type": "array", - "items": { "type": "string" }, - "description": "File patterns or values to exclude" - }, - "guidelines": { - "type": "array", - "items": { "type": "string" }, - "description": "Human-readable guidelines for this rule" - }, - "components": { - "type": "object", - "description": "Component-specific rule configuration" - } + "validation": { "type": "object" }, + "exceptions": { "type": "array", "items": { "type": "string" } }, + "guidelines": { "type": "array", "items": { "type": "string" } } } } } diff --git a/packages/dss-rules/templates/ds.config.json b/packages/dss-rules/templates/ds.config.json new file mode 100644 index 0000000..b7497e8 --- /dev/null +++ b/packages/dss-rules/templates/ds.config.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://dss.overbits.luz.uy/schemas/ds.config.schema.json", + "name": "{{PROJECT_NAME}}", + "version": "1.0.0", + "rules": { + "package": "@dss/rules", + "version": "^1.0.0" + }, + "analysis": { + "include": ["src/**/*.{ts,tsx,js,jsx,css,scss}"], + "exclude": ["**/node_modules/**", "**/*.test.*", "**/*.spec.*"], + "output": ".dss/analysis_graph.json" + }, + "metrics": { + "upload": true, + "dashboardUrl": "https://dss.overbits.luz.uy/api/metrics" + }, + "ci": { + "blocking": true, + "skipPattern": "[dss-skip]", + "baselineBranch": "main" + } +} diff --git a/packages/dss-rules/templates/dss-folder/.gitkeep b/packages/dss-rules/templates/dss-folder/.gitkeep new file mode 100644 index 0000000..351229e --- /dev/null +++ b/packages/dss-rules/templates/dss-folder/.gitkeep @@ -0,0 +1 @@ +# This folder is created by DSS initialization diff --git a/packages/dss-rules/templates/gitea-workflow.yml b/packages/dss-rules/templates/gitea-workflow.yml new file mode 100644 index 0000000..323f5e9 --- /dev/null +++ b/packages/dss-rules/templates/gitea-workflow.yml @@ -0,0 +1,122 @@ +name: DSS Design System Validation + +on: + push: + branches: ['*'] + pull_request: + branches: [main, develop] + +env: + DSS_MODE: ci + DSS_DASHBOARD_URL: ${{ vars.DSS_DASHBOARD_URL || 'https://dss.overbits.luz.uy/api/metrics' }} + +jobs: + dss-validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check for [dss-skip] in commit message + id: skip-check + run: | + COMMIT_MSG=$(git log -1 --pretty=%B) + if echo "$COMMIT_MSG" | grep -q "\[dss-skip\]"; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "::warning::DSS validation skipped via [dss-skip] flag" + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - name: Run DSS Rules Validation + if: steps.skip-check.outputs.skip != 'true' + id: validate + run: | + # Run validation and capture output + npx dss-rules --ci --json src/ > dss-report.json 2>&1 || true + + # Check results + ERRORS=$(jq '.totalErrors' dss-report.json) + WARNINGS=$(jq '.totalWarnings' dss-report.json) + + echo "errors=$ERRORS" >> $GITHUB_OUTPUT + echo "warnings=$WARNINGS" >> $GITHUB_OUTPUT + + # Print summary + echo "## DSS Validation Results" >> $GITHUB_STEP_SUMMARY + echo "- Errors: $ERRORS" >> $GITHUB_STEP_SUMMARY + echo "- Warnings: $WARNINGS" >> $GITHUB_STEP_SUMMARY + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::DSS validation failed with $ERRORS errors" + exit 1 + fi + + - name: Check for version drift + if: steps.skip-check.outputs.skip != 'true' + run: | + CURRENT_VERSION=$(npm list @dss/rules --json 2>/dev/null | jq -r '.dependencies["@dss/rules"].version // "unknown"') + LATEST_VERSION=$(npm view @dss/rules version 2>/dev/null || echo "unknown") + + if [ "$CURRENT_VERSION" != "$LATEST_VERSION" ] && [ "$LATEST_VERSION" != "unknown" ]; then + echo "::warning::@dss/rules version drift detected: using $CURRENT_VERSION, latest is $LATEST_VERSION" + fi + + - name: Upload metrics to dashboard + if: steps.skip-check.outputs.skip != 'true' && always() + run: | + if [ -f dss-report.json ]; then + # Extract metrics for upload + jq '{ + project: "${{ github.repository }}", + branch: "${{ github.ref_name }}", + commit: "${{ github.sha }}", + timestamp: now | todate, + metrics: { + totalFiles: .totalFiles, + passedFiles: .passedFiles, + failedFiles: .failedFiles, + totalErrors: .totalErrors, + totalWarnings: .totalWarnings, + rulesVersion: .rulesVersion + }, + fileResults: [.fileResults[] | { + file: .file, + errors: (.errors | length), + warnings: (.warnings | length), + violations: [.errors[], .warnings[] | { + rule: .rule, + line: .line, + column: .column + }] + }] + }' dss-report.json > metrics-payload.json + + # Upload to dashboard (non-blocking) + curl -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${{ secrets.DSS_API_TOKEN }}" \ + -d @metrics-payload.json \ + "$DSS_DASHBOARD_URL/upload" \ + --fail-with-body || echo "::warning::Failed to upload metrics to dashboard" + fi + + - name: Upload validation report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: dss-validation-report + path: dss-report.json + retention-days: 30 diff --git a/packages/dss-rules/templates/github-workflow.yml b/packages/dss-rules/templates/github-workflow.yml new file mode 100644 index 0000000..a9d83a5 --- /dev/null +++ b/packages/dss-rules/templates/github-workflow.yml @@ -0,0 +1,152 @@ +# DSS Design System Validation - GitHub Actions +# Generated by @dss/rules init +# +# This workflow validates design system compliance and uploads metrics +# to the DSS dashboard for portfolio-wide visibility. +# +# Required Secrets: +# DSS_DASHBOARD_URL: URL to DSS metrics API (e.g., https://dss.example.com) +# DSS_API_TOKEN: Authentication token for metrics upload + +name: DSS Validate + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master] + +env: + NODE_VERSION: '20' + +jobs: + validate: + name: Design System Validation + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for baseline comparison + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + # Check for break-glass [dss-skip] in commit message + - name: Check for [dss-skip] + id: skip-check + run: | + COMMIT_MSG=$(git log -1 --pretty=%B) + if echo "$COMMIT_MSG" | grep -q '\[dss-skip\]'; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "::warning::DSS validation skipped via [dss-skip] commit message" + echo "::warning::Commit: $(git log -1 --pretty='%h %s')" + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + # Check @dss/rules version drift + - name: Check rules version + if: steps.skip-check.outputs.skip != 'true' + run: | + INSTALLED=$(npm list @dss/rules --json 2>/dev/null | jq -r '.dependencies["@dss/rules"].version // "not-installed"') + LATEST=$(npm view @dss/rules version 2>/dev/null || echo "unknown") + + echo "Installed @dss/rules: $INSTALLED" + echo "Latest @dss/rules: $LATEST" + + if [ "$INSTALLED" != "$LATEST" ] && [ "$LATEST" != "unknown" ]; then + echo "::warning::@dss/rules is outdated ($INSTALLED vs $LATEST). Consider updating." + fi + + # Run DSS validation + - name: Run DSS validation + if: steps.skip-check.outputs.skip != 'true' + id: validate + run: | + # Run validation with CI mode (strict, JSON output) + npm run dss:validate:ci || echo "validation_failed=true" >> $GITHUB_OUTPUT + + # Extract summary for PR comment + if [ -f .dss/results.json ]; then + ERRORS=$(jq -r '.metrics.totalErrors // 0' .dss/results.json) + WARNINGS=$(jq -r '.metrics.totalWarnings // 0' .dss/results.json) + SCORE=$(jq -r '.metrics.adoptionScore // 0' .dss/results.json) + + echo "errors=$ERRORS" >> $GITHUB_OUTPUT + echo "warnings=$WARNINGS" >> $GITHUB_OUTPUT + echo "score=$SCORE" >> $GITHUB_OUTPUT + fi + + # Upload metrics to DSS dashboard + - name: Upload metrics to dashboard + if: steps.skip-check.outputs.skip != 'true' && always() + continue-on-error: true + run: | + if [ ! -f .dss/results.json ]; then + echo "No results file found, skipping upload" + exit 0 + fi + + # Add git metadata to results + jq --arg branch "${{ github.ref_name }}" \ + --arg commit "${{ github.sha }}" \ + --arg repo "${{ github.repository }}" \ + '. + {branch: $branch, commit: $commit, project: $repo}' \ + .dss/results.json > .dss/upload.json + + curl -X POST "${DSS_DASHBOARD_URL}/api/metrics/upload" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${DSS_API_TOKEN}" \ + -d @.dss/upload.json \ + --fail --silent --show-error + env: + DSS_DASHBOARD_URL: ${{ secrets.DSS_DASHBOARD_URL }} + DSS_API_TOKEN: ${{ secrets.DSS_API_TOKEN }} + + # Comment on PR with results + - name: Comment on PR + if: github.event_name == 'pull_request' && steps.skip-check.outputs.skip != 'true' + uses: actions/github-script@v7 + with: + script: | + const errors = '${{ steps.validate.outputs.errors }}' || '0'; + const warnings = '${{ steps.validate.outputs.warnings }}' || '0'; + const score = '${{ steps.validate.outputs.score }}' || 'N/A'; + + const status = errors === '0' ? 'āœ…' : 'āŒ'; + const body = `## ${status} DSS Validation Results + + | Metric | Value | + |--------|-------| + | Adoption Score | ${score}% | + | Errors | ${errors} | + | Warnings | ${warnings} | + + ${errors !== '0' ? 'āš ļø Please fix design system violations before merging.' : 'šŸŽ‰ All design system checks passed!'} + + --- + *Powered by @dss/rules*`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + + # Fail if validation errors (authoritative enforcement) + - name: Check validation result + if: steps.skip-check.outputs.skip != 'true' + run: | + if [ "${{ steps.validate.outputs.validation_failed }}" = "true" ]; then + echo "::error::DSS validation failed with errors. Please fix violations." + exit 1 + fi diff --git a/packages/dss-rules/templates/gitignore-additions.txt b/packages/dss-rules/templates/gitignore-additions.txt new file mode 100644 index 0000000..8d68569 --- /dev/null +++ b/packages/dss-rules/templates/gitignore-additions.txt @@ -0,0 +1,9 @@ +# DSS Design System (generated files - do not commit) +.dss/analysis_graph.json +.dss/cache/ +.dss/metrics.json +.dss/baseline.json + +# Keep config and schema +!.dss/ds.config.json +!.dss/.gitkeep diff --git a/packages/dss-rules/templates/gitlab-ci.yml b/packages/dss-rules/templates/gitlab-ci.yml new file mode 100644 index 0000000..4d55f9a --- /dev/null +++ b/packages/dss-rules/templates/gitlab-ci.yml @@ -0,0 +1,126 @@ +# DSS Design System Validation - GitLab CI +# Generated by @dss/rules init +# +# This workflow validates design system compliance and uploads metrics +# to the DSS dashboard for portfolio-wide visibility. +# +# Required Variables: +# DSS_DASHBOARD_URL: URL to DSS metrics API (e.g., https://dss.example.com) +# DSS_API_TOKEN: Authentication token for metrics upload + +stages: + - validate + +variables: + NODE_VERSION: "20" + +.node-cache: + cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - node_modules/ + - .npm/ + +dss-validate: + stage: validate + image: node:${NODE_VERSION} + extends: .node-cache + script: + # Install dependencies + - npm ci --cache .npm --prefer-offline + + # Check for break-glass [dss-skip] in commit message + - | + COMMIT_MSG=$(git log -1 --pretty=%B) + if echo "$COMMIT_MSG" | grep -q '\[dss-skip\]'; then + echo "āš ļø DSS validation skipped via [dss-skip] commit message" + echo "Commit: $(git log -1 --pretty='%h %s')" + exit 0 + fi + + # Check @dss/rules version drift + - | + INSTALLED=$(npm list @dss/rules --json 2>/dev/null | jq -r '.dependencies["@dss/rules"].version // "not-installed"') + LATEST=$(npm view @dss/rules version 2>/dev/null || echo "unknown") + echo "Installed @dss/rules: $INSTALLED" + echo "Latest @dss/rules: $LATEST" + if [ "$INSTALLED" != "$LATEST" ] && [ "$LATEST" != "unknown" ]; then + echo "āš ļø @dss/rules is outdated ($INSTALLED vs $LATEST). Consider updating." + fi + + # Run DSS validation + - npm run dss:validate:ci || VALIDATION_FAILED=true + + # Upload metrics to dashboard + - | + if [ -f .dss/results.json ]; then + jq --arg branch "$CI_COMMIT_REF_NAME" \ + --arg commit "$CI_COMMIT_SHA" \ + --arg repo "$CI_PROJECT_PATH" \ + '. + {branch: $branch, commit: $commit, project: $repo}' \ + .dss/results.json > .dss/upload.json + + curl -X POST "${DSS_DASHBOARD_URL}/api/metrics/upload" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${DSS_API_TOKEN}" \ + -d @.dss/upload.json \ + --fail --silent --show-error || echo "āš ļø Failed to upload metrics (non-blocking)" + fi + + # Fail if validation errors + - | + if [ "$VALIDATION_FAILED" = "true" ]; then + echo "āŒ DSS validation failed with errors. Please fix violations." + exit 1 + fi + + artifacts: + when: always + paths: + - .dss/results.json + expire_in: 1 week + reports: + codequality: .dss/results.json + + rules: + - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "develop" + - if: $CI_MERGE_REQUEST_IID + +# Optional: MR comment with results (requires GITLAB_TOKEN with API access) +dss-mr-comment: + stage: validate + image: curlimages/curl:latest + needs: + - job: dss-validate + artifacts: true + script: + - | + if [ ! -f .dss/results.json ]; then + echo "No results file, skipping MR comment" + exit 0 + fi + + ERRORS=$(jq -r '.metrics.totalErrors // 0' .dss/results.json) + WARNINGS=$(jq -r '.metrics.totalWarnings // 0' .dss/results.json) + SCORE=$(jq -r '.metrics.adoptionScore // 0' .dss/results.json) + + if [ "$ERRORS" = "0" ]; then + STATUS="āœ…" + MESSAGE="šŸŽ‰ All design system checks passed!" + else + STATUS="āŒ" + MESSAGE="āš ļø Please fix design system violations before merging." + fi + + BODY="## $STATUS DSS Validation Results\n\n| Metric | Value |\n|--------|-------|\n| Adoption Score | ${SCORE}% |\n| Errors | $ERRORS |\n| Warnings | $WARNINGS |\n\n$MESSAGE\n\n---\n*Powered by @dss/rules*" + + curl --request POST \ + --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \ + --header "Content-Type: application/json" \ + --data "{\"body\": \"$BODY\"}" \ + "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes" \ + || echo "āš ļø Failed to post MR comment (non-blocking)" + + rules: + - if: $CI_MERGE_REQUEST_IID && $GITLAB_TOKEN + allow_failure: true