openpencil/scripts/measure-v0-hex-coverage.ts

148 lines
5.6 KiB
TypeScript
Raw Normal View History

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<string>(
[
'#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 `$<token>` 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<string, string> = {
'#1D4ED8': 'color-accent',
'#EFF6FF': 'color-info-bg',
'#B45309': 'color-warning-text',
'#B91C1C': 'color-danger-text',
};
const MERGE_MAP_NORMALIZED: Record<string, string> = 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<string>();
const hexUsageMap = new Map<string, string[]>(); // 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<string>([
...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<string>([...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();