import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { getSemanticPaletteHex } from '../packages/pen-core/src/variables/semantic-palette.js'; const BUILDERS_DIR = 'packages/pen-core/src/element-builders'; const HEX_REGEX = /#[0-9A-Fa-f]{6}\b/g; /** * §3.4 Builder-private literals — these hex values are intentionally * hardcoded in v0/v1 builders and are NOT candidates for semantic tokens. * They are excluded from the cover-rate denominator. * * Rationale per design decision (2026-05-03 Codex review, B route): * - Gray/neutral scale (#111827 … #F9FAFB): CSS-reset / skeleton shades * that don't carry semantic meaning and would pollute the palette. * - iOS system green (#34C759), iOS separator (#E5E5EA): platform-specific * literals used only in iOS-flavored builders. * - Pure black (#000000): universal default, not a design token. */ const BUILDER_PRIVATE_HEX = new Set( [ '#000000', '#111827', '#4B5563', '#6B7280', '#9CA3AF', '#D1D5DB', '#E5E7EB', '#F9FAFB', '#34C759', '#E5E5EA', ].map((h) => h.toUpperCase()), ); /** * §3.1 + §7.4 Merge map — hex values that map to existing semantic tokens * with ≤ 5% accepted color drift, rather than introducing new tokens. * * Rationale: per spec §3.1 line 75 + §7.4 line 287, semantic tokens stay * intentionally small. Near-shade variants (e.g. blue-700 vs blue-600) are * absorbed into the closest existing token instead of expanding the palette. * v1 builders will emit `$` for these hex inputs, accepting a small * shade drift in exchange for a stable, memorable palette. * * Per-entry color drift (visual ΔE, approximate): * - #1D4ED8 (blue-700) → $color-accent (#2563EB blue-600) ~3% drift * - #EFF6FF (blue-50) → $color-info-bg (#DBEAFE blue-100) ~4% drift * - #B45309 (amber-700) → $color-warning-text (#92400E amber-800) ~3% drift * - #B91C1C (red-700) → $color-danger-text (#991B1B red-800) ~3% drift */ const MERGE_MAP: Record = { '#1D4ED8': 'color-accent', '#EFF6FF': 'color-info-bg', '#B45309': 'color-warning-text', '#B91C1C': 'color-danger-text', }; const MERGE_MAP_NORMALIZED: Record = Object.fromEntries( Object.entries(MERGE_MAP).map(([h, t]) => [h.toUpperCase(), t]), ); function extractHexFromFile(path: string): string[] { const content = readFileSync(path, 'utf-8'); return Array.from(content.matchAll(HEX_REGEX), (m) => m[0].toUpperCase()); } function main() { const v0Files = readdirSync(BUILDERS_DIR).filter( (f) => f.endsWith('.ts') && !f.includes('-v1') && f !== 'index.ts' && f !== 'helpers.ts' && f !== 'resolve-theme.ts', ); const v0HexSetRaw = new Set(); const hexUsageMap = new Map(); // hex → list of files using it for (const f of v0Files) { const hexes = extractHexFromFile(join(BUILDERS_DIR, f)); hexes.forEach((h) => { v0HexSetRaw.add(h); if (!hexUsageMap.has(h)) hexUsageMap.set(h, []); hexUsageMap.get(h)!.push(f); }); } // Apply §3.4 exclusion list — remove builder-private literals from denominator const v0HexSet = new Set([...v0HexSetRaw].filter((h) => !BUILDER_PRIVATE_HEX.has(h))); const excludedCount = v0HexSetRaw.size - v0HexSet.size; const lightTokens = getSemanticPaletteHex('Light'); const darkTokens = getSemanticPaletteHex('Dark'); const tokenHexSet = new Set([ ...Object.values(lightTokens) .filter((v): v is string => typeof v === 'string') .map((h) => h.toUpperCase()), ...Object.values(darkTokens) .filter((v): v is string => typeof v === 'string') .map((h) => h.toUpperCase()), ]); // Direct cover: hex literal exists verbatim in palette const directCovered = [...v0HexSet].filter((h) => tokenHexSet.has(h)).sort(); // Merge cover: hex literal mapped to an existing token via §3.1 / §7.4 merge map const mergeCovered = [...v0HexSet] .filter((h) => !tokenHexSet.has(h) && h in MERGE_MAP_NORMALIZED) .sort(); // Total cover = direct ∪ merge const totalCoveredSet = new Set([...directCovered, ...mergeCovered]); const uncovered = [...v0HexSet].filter((h) => !totalCoveredSet.has(h)).sort(); const coverRate = totalCoveredSet.size / v0HexSet.size; console.log(`v0 distinct hex literals (raw): ${v0HexSetRaw.size}`); console.log(`§3.4 builder-private excluded: ${excludedCount}`); console.log(`v0 semantic hex (post-exclusion): ${v0HexSet.size}`); console.log(` direct cover: ${directCovered.length}`); console.log( ` merge cover (${mergeCovered.length} of ${Object.keys(MERGE_MAP).length} merge-map entries):`, ); for (const hex of mergeCovered) { console.log(` ${hex} → $${MERGE_MAP_NORMALIZED[hex]}`); } console.log( ` total cover: ${totalCoveredSet.size}/${v0HexSet.size} = ${(coverRate * 100).toFixed(1)}%`, ); if (uncovered.length > 0) { console.log(`\nUncovered semantic hex (with usage):`); uncovered.forEach((h) => { const users = hexUsageMap.get(h)!; console.log( ` ${h} — used in ${users.length} file(s): ${users.slice(0, 3).join(', ')}${users.length > 3 ? ` (+${users.length - 3} more)` : ''}`, ); }); } if (coverRate < 0.95) { console.error( `\n❌ HARD GATE FAILED: semantic cover rate ${(coverRate * 100).toFixed(1)}% < 95%`, ); console.error('Per spec §7.4, must pause and revisit D2 namespace boundary before P2.'); process.exit(1); } console.log(`\n✅ HARD GATE PASSED (${(coverRate * 100).toFixed(1)}% ≥ 95%)`); } main();