mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 08:57:12 +00:00
159 lines
4.8 KiB
JavaScript
159 lines
4.8 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const ROOT = process.cwd();
|
|
const SRC_DIR = path.join(ROOT, 'src');
|
|
const INDEX_HTML = path.join(ROOT, 'index.html');
|
|
|
|
const IGNORE_DIRS = new Set([
|
|
'node_modules',
|
|
'dist',
|
|
'coverage',
|
|
'.git',
|
|
]);
|
|
|
|
const THEME_OWNER_ALLOWLIST = new Set([
|
|
'src/utils/theme.ts',
|
|
'index.html',
|
|
]);
|
|
|
|
const findings = [];
|
|
|
|
function toRelative(absPath) {
|
|
return path.relative(ROOT, absPath).replaceAll(path.sep, '/');
|
|
}
|
|
|
|
function lineForIndex(content, index) {
|
|
let line = 1;
|
|
for (let i = 0; i < index; i += 1) {
|
|
if (content[i] === '\n') line += 1;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
function addFinding({ file, index, rule, message }) {
|
|
const line = lineForIndex(file.content, index);
|
|
findings.push({
|
|
file: file.relativePath,
|
|
line,
|
|
rule,
|
|
message,
|
|
});
|
|
}
|
|
|
|
function collectFiles(dir) {
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
const files = [];
|
|
|
|
for (const entry of entries) {
|
|
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
const fullPath = path.join(dir, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
files.push(...collectFiles(fullPath));
|
|
continue;
|
|
}
|
|
|
|
if (!entry.isFile()) continue;
|
|
if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(entry.name)) continue;
|
|
files.push(fullPath);
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
function analyzeFile(absPath) {
|
|
const relativePath = toRelative(absPath);
|
|
const content = fs.readFileSync(absPath, 'utf8');
|
|
const file = { relativePath, content };
|
|
const isThemeOwner = THEME_OWNER_ALLOWLIST.has(relativePath);
|
|
const isTestFile = /\/__tests__\//.test(relativePath) || /\.test\.(ts|tsx|js|jsx)$/.test(relativePath);
|
|
|
|
if (!isThemeOwner && !isTestFile) {
|
|
const darkClassPattern = /document\.documentElement\.classList\.(?:add|remove|toggle)\(\s*['"]dark['"]/g;
|
|
for (const match of content.matchAll(darkClassPattern)) {
|
|
addFinding({
|
|
file,
|
|
index: match.index ?? 0,
|
|
rule: 'theme-owner/root-dark-class',
|
|
message: "Only theme owners may mutate document.documentElement.classList('dark'). Use src/utils/theme.ts helpers.",
|
|
});
|
|
}
|
|
|
|
const directThemeStoragePattern = /localStorage\.(?:getItem|setItem|removeItem)\(\s*['"](pulseThemePreference|darkMode|pulse_dark_mode)['"]/g;
|
|
for (const match of content.matchAll(directThemeStoragePattern)) {
|
|
addFinding({
|
|
file,
|
|
index: match.index ?? 0,
|
|
rule: 'theme-owner/storage-keys',
|
|
message: 'Only theme owners may read/write theme storage keys directly. Use src/utils/theme.ts.',
|
|
});
|
|
}
|
|
|
|
const storageKeyConstantPattern = /\bSTORAGE_KEYS\.(?:THEME_PREFERENCE|DARK_MODE)\b/g;
|
|
for (const match of content.matchAll(storageKeyConstantPattern)) {
|
|
addFinding({
|
|
file,
|
|
index: match.index ?? 0,
|
|
rule: 'theme-owner/storage-constants',
|
|
message: 'Do not consume STORAGE_KEYS.THEME_PREFERENCE/DARK_MODE directly outside src/utils/theme.ts.',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Full-screen surfaces should use semantic backgrounds so light/dark always resolve.
|
|
const classLiteralPatterns = [
|
|
/class(?:Name)?\s*=\s*["']([^"']*min-h-screen[^"']*)["']/g,
|
|
/class(?:Name)?\s*=\s*{\s*`([^`]*min-h-screen[^`]*)`\s*}/g,
|
|
];
|
|
|
|
for (const pattern of classLiteralPatterns) {
|
|
for (const match of content.matchAll(pattern)) {
|
|
const classString = match[1] ?? '';
|
|
const hasSemanticBackground = /\bbg-(?:base|surface|surface-alt|surface-hover)\b/.test(classString);
|
|
const hasHardcodedBackground = /\bbg-(?:white|black|slate-\d{2,3}|gray-\d{2,3}|zinc-\d{2,3}|neutral-\d{2,3}|stone-\d{2,3}|red-\d{2,3}|orange-\d{2,3}|amber-\d{2,3}|yellow-\d{2,3}|lime-\d{2,3}|green-\d{2,3}|emerald-\d{2,3}|teal-\d{2,3}|cyan-\d{2,3}|sky-\d{2,3}|blue-\d{2,3}|indigo-\d{2,3}|violet-\d{2,3}|purple-\d{2,3}|fuchsia-\d{2,3}|pink-\d{2,3}|rose-\d{2,3})\b/.test(classString);
|
|
|
|
if (!hasSemanticBackground && hasHardcodedBackground) {
|
|
addFinding({
|
|
file,
|
|
index: match.index ?? 0,
|
|
rule: 'theme-shell/semantic-background',
|
|
message: 'min-h-screen wrappers must use semantic backgrounds (bg-base/bg-surface/bg-surface-alt).',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const filesToAnalyze = [
|
|
...collectFiles(SRC_DIR),
|
|
INDEX_HTML,
|
|
];
|
|
|
|
for (const filePath of filesToAnalyze) {
|
|
if (!fs.existsSync(filePath)) continue;
|
|
analyzeFile(filePath);
|
|
}
|
|
|
|
if (findings.length === 0) {
|
|
console.log('Theme audit passed with no findings.');
|
|
process.exit(0);
|
|
}
|
|
|
|
console.error('Theme audit findings:');
|
|
for (const finding of findings) {
|
|
console.error(`- ${finding.file}:${finding.line} [${finding.rule}] ${finding.message}`);
|
|
}
|
|
|
|
const byRule = new Map();
|
|
for (const finding of findings) {
|
|
byRule.set(finding.rule, (byRule.get(finding.rule) ?? 0) + 1);
|
|
}
|
|
|
|
console.error('\nSummary by rule:');
|
|
for (const [rule, count] of byRule.entries()) {
|
|
console.error(`- ${rule}: ${count}`);
|
|
}
|
|
|
|
process.exit(1);
|