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>
388 lines
11 KiB
JavaScript
388 lines
11 KiB
JavaScript
/**
|
|
* @dss/rules - Design System Rules Package
|
|
*
|
|
* 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');
|
|
|
|
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
|
|
*/
|
|
function loadRules() {
|
|
const rules = {};
|
|
const rulesDir = path.join(__dirname, '..', 'rules');
|
|
|
|
for (const category of CATEGORIES) {
|
|
const rulePath = path.join(rulesDir, `${category}.json`);
|
|
if (fs.existsSync(rulePath)) {
|
|
try {
|
|
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
|
|
*/
|
|
function getRulesByCategory(category) {
|
|
const rules = loadRules();
|
|
return rules[category] || null;
|
|
}
|
|
|
|
/**
|
|
* Get all rule IDs across all categories
|
|
*/
|
|
function getAllRuleIds() {
|
|
const rules = loadRules();
|
|
const ids = [];
|
|
for (const [category, ruleSet] of Object.entries(rules)) {
|
|
if (ruleSet?.rules) {
|
|
for (const rule of ruleSet.rules) {
|
|
ids.push(`${category}/${rule.id}`);
|
|
}
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
/**
|
|
* Get a specific rule by full ID (category/rule-id)
|
|
*/
|
|
function getRule(ruleId) {
|
|
const [category, id] = ruleId.split('/');
|
|
const ruleSet = getRulesByCategory(category);
|
|
if (!ruleSet?.rules) return null;
|
|
return ruleSet.rules.find(r => r.id === id) || null;
|
|
}
|
|
|
|
/**
|
|
* Get rule severity
|
|
*/
|
|
function getRuleSeverity(ruleId) {
|
|
const rule = getRule(ruleId);
|
|
if (!rule) return 'warning';
|
|
if (rule.severity) return rule.severity;
|
|
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
|
|
*/
|
|
function getVersion() {
|
|
const packagePath = path.join(__dirname, '..', 'package.json');
|
|
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf-8'));
|
|
return pkg.version;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
blockingRules: 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
|
|
validateContent,
|
|
validateFile,
|
|
validateFiles,
|
|
isLineIgnored,
|
|
getApplicableCategories,
|
|
|
|
// Baseline comparison
|
|
compareWithBaseline,
|
|
|
|
// CI helpers
|
|
getCIConfig,
|
|
shouldSkipValidation,
|
|
|
|
// Token helpers
|
|
getRequiredTokens,
|
|
|
|
// Metadata
|
|
getVersion,
|
|
CATEGORIES,
|
|
IGNORE_PATTERN,
|
|
SKIP_COMMIT_PATTERN
|
|
};
|