#!/usr/bin/env node // CI ratchet: count per-file occurrences of every design-token bypass category // from O2_TOKEN_MIGRATION_PLAN.md §3 (A–R). Debt can only shrink. // // node scripts/check-design-consistency.mjs # fail if any file/category exceeds baseline // node scripts/check-design-consistency.mjs --strict # ALSO fail if the baseline is stale (has slack) — CI mode // node scripts/check-design-consistency.mjs --baseline # (re)write design-debt-baseline.json // // --strict is what CI runs (lint:design:strict). Plain mode lets a file sit // below its baseline (leaving headroom a future raw token could refill without // tripping the guard); --strict forbids that slack, so every bypass category is // strictly monotonic-down and NO new raw token can land anywhere — including in a // file that still carries pre-existing debt. Improve → re-baseline → commit. // // Phase B: ratchet mode (this file). Phase G: delete the baseline → zero tolerance // (except `stylePxUnit`, which stays ratchet-only per §12.4). import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from "node:fs"; import { join, extname, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SRC_DIR = join(__dirname, "..", "src"); const BASELINE = join(__dirname, "design-debt-baseline.json"); // Declared homes for literal hex in .ts (§12.3). Excluded from the `tsHex` // category because their CONSUMER cannot resolve a CSS custom property, so a // `var(--color-*)` is impossible there — the hex is the only option: // • ECharts option objects / canvas 2D contexts (dashboard chart converters) // • user-editable chart-template source strings (customChartTemplates/*) // • Monaco editor theme + language token colours (vrlLanguage*) // • SVG/canvas trace-tree rendering // • the theme source-of-truth itself (theme.ts defines the runtime palette) // • JS that builds styled log/status HTML from a fixed status→colour map // Entries ending in "/" match by directory prefix; others by exact suffix. const TS_HEX_ALLOWLIST = [ // categorical / series palettes + theme source "utils/dashboard/colorPalette.ts", "constants/themes.ts", "utils/traces/traceColors.ts", "utils/theme.ts", "utils/themeManager.ts", "utils/chartTheme.ts", // sanctioned seam: light-theme fallback constants for jsdom/pre-CSS // ECharts / canvas / geo chart builders — options are serialised, no var() resolves "utils/dashboard/", "composables/dashboard/", "components/dashboards/addPanel/customChartExamples/", "utils/traces/convertTraceData.ts", "utils/traces/treeVisualizationEngine.ts", "composables/useServiceGraphTree.ts", "composables/useMetricsExplorer.ts", // Monaco editor language/theme definitions (colour strings the editor lib reads) "utils/query/vrlLanguage.ts", "utils/query/vrlLanguageDefinition.ts", // JS that builds styled HTML/canvas from a fixed status→colour map "utils/logs/convertLogData.ts", "utils/logs/statusParser.ts", "utils/logs/keyValueParser.ts", // LLM / metric / latency chart-panel series palettes (ECharts, no var()) "plugins/traces/llmTrendPanel.utils.ts", "plugins/traces/config/", "enterprise/components/billings/usageDailyPanelSchema.ts", "composables/useLatencyInsightsDashboard.ts", "composables/useMetricsCorrelationDashboard.ts", "utils/metrics/metricPalette.ts", "utils/traces/treeTooltipHelpers.ts", "utils/alerts/alertChartData.ts", "composables/useAlertForm.ts", // ECharts markLine colour // Ingestion vendor brand accents (D12-style fixed brand colour; `tone` is a // reserved-future field that is never read — see setup-card content types) "components/ingestion/setupCard/content/", "components/ingestion/ai/content/", ]; // The sanctioned dark-mode seams (§3.R.1) — the only files allowed to read theme / // declare an isDark flag, so they are exempt from the darkMechanism count. useTheme + // chartTheme are the JS *consumption* seams; themeManager is the theme *applier* — it // resolves store.state.theme into the mode to apply (upstream of every token, so it // cannot consume a token without circularity). // • ThemeSwitcher.vue is the toggle control — can't be written without naming // "dark" (a `darkMode` flag or `theme === 'dark'` compare), so it's the one // canonical home for that decision, not the fragmentation the category targets. // • convertLogData.ts reads --color-theme-accent to paint an ECharts canvas bar. // applyThemeColors (utils/theme.ts) sets that token on document.body in dark and // document.documentElement in light, so the consumer must know which element // carries it — a var() can't resolve on a . const DARK_SEAM_ALLOWLIST = [ "composables/useTheme.ts", "utils/chartTheme.ts", "utils/themeManager.ts", "components/ThemeSwitcher.vue", "utils/logs/convertLogData.ts", ]; // Files allowed to keep an UNSCOPED blocks (scoped AND unscoped — // styleBlocks() collects every block regardless of the `scoped` attribute). const STYLE_ONLY = { styleBlockHex: /#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(/g, stylePxUnit: /\b(?:[2-9]|[0-9]{2,})(?:\.[0-9]+)?px\b/g, // 1px hairlines exempt // NOTE: rawVarInComponent (F.6) is NOT a flat regex — it is context-aware and // computed by countRawVarInComponent() below. See that function for why. }; function walk(dir, files = []) { for (const entry of readdirSync(dir)) { if (entry === "node_modules" || entry === "dist") continue; const full = join(dir, entry); const st = statSync(full); if (st.isDirectory()) walk(full, files); else if (extname(entry) === ".vue" || extname(entry) === ".ts") files.push(full); } return files; } function styleBlocks(text) { const out = []; const re = /]*>([\s\S]*?)<\/style>/g; let m; while ((m = re.exec(text)) !== null) out.push(m[1]); return out.join("\n"); } // ── rawVarInComponent (F.6), CONTEXT-AWARE ───────────────────────────────── // A raw `var(--color-*)` in a component