feat: Enterprise DSS architecture implementation
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
DSS
2025-12-11 09:41:36 -03:00
parent ab8769933d
commit 9dbd56271e
27 changed files with 3888 additions and 398 deletions

View File

@@ -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
};