Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/lib/impeccable-config.mjs
1/**2* CLI-side reader/writer for the unified `.impeccable` config.3*4* The CLI (published to npm) and the skill scripts (bundled into the install)5* live in separate trees and cannot share runtime code, so this duplicates a6* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector7* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,8* ignore filtering, and exclude marker in sync if either side changes.9*10* Schema (config.json shared / config.local.json gitignored, per-developer):11* {12* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },13* "hook": { "consent": "accepted" | "declined", ... },14* "updateCheck": bool15* }16*/1718import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';19import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';2021export function getConfigPath(root) {22return join(root, '.impeccable', 'config.json');23}2425export function getLocalConfigPath(root) {26return join(root, '.impeccable', 'config.local.json');27}2829function safeReadJson(filePath) {30try {31const raw = JSON.parse(readFileSync(filePath, 'utf-8'));32return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;33} catch {34return null;35}36}3738function hookSection(raw) {39return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;40}4142function detectorSection(raw) {43return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;44}4546const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);4748const DEFAULT_DETECTION_CONFIG = Object.freeze({49ignoreRules: [],50ignoreFiles: [],51ignoreValues: [],52designSystem: { enabled: true },53});5455function cloneDetectionConfig() {56return {57ignoreRules: [],58ignoreFiles: [],59ignoreValues: [],60designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },61};62}6364function cloneRawDetectionConfig() {65return {66ignoreRules: [],67ignoreFiles: [],68ignoreValues: [],69};70}7172function applyDetectionConfigSource(config, raw) {73if (!raw || typeof raw !== 'object') return config;74if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {75config.designSystem = {76...config.designSystem,77enabled: raw.designSystem.enabled === false ? false : true,78};79}80if (Array.isArray(raw.ignoreRules)) {81config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);82}83if (Array.isArray(raw.ignoreFiles)) {84config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);85}86if (Array.isArray(raw.ignoreValues)) {87config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);88}89return config;90}9192function uniqueStrings(values) {93return Array.from(new Set(values.map(String)));94}9596/**97* Detector filters shared by `npx impeccable detect` and the design hook.98* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when99* the hook is disabled, but they honor the same ignore rules and design-system100* toggle.101*/102export function readDetectionConfig(root) {103const config = cloneDetectionConfig();104for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {105const raw = safeReadJson(filePath);106// Back-compat: old builds stored detector filters under hook.*.107applyDetectionConfigSource(config, hookSection(raw));108applyDetectionConfigSource(config, detectorSection(raw));109}110return config;111}112113export function readRawDetectionConfig(root, opts = {}) {114const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));115const config = cloneRawDetectionConfig();116applyDetectionConfigSource(config, hookSection(raw));117applyDetectionConfigSource(config, detectorSection(raw));118return config;119}120121export function writeDetectionConfig(root, detectorConfig, opts = {}) {122const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);123if (opts.local) ensureConfigGitExclude(root);124const existing = safeReadJson(filePath) || {};125const existingHook = hookSection(existing);126const nextHook = stripDetectorKeys(existingHook);127const nextDetector = {128...(detectorSection(existing) || {}),129...normalizeDetectionConfigForWrite(detectorConfig),130};131const next = {132...existing,133detector: nextDetector,134};135if (nextHook && Object.keys(nextHook).length > 0) {136next.hook = nextHook;137} else {138delete next.hook;139}140mkdirSync(dirname(filePath), { recursive: true });141writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);142return filePath;143}144145function normalizeDetectionConfigForWrite(config) {146const out = {};147if (Array.isArray(config?.ignoreRules)) {148out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));149}150if (Array.isArray(config?.ignoreFiles)) {151out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));152}153out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);154if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {155out.designSystem = {156enabled: config.designSystem.enabled === false ? false : true,157};158}159return out;160}161162function stripDetectorKeys(raw) {163if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;164const out = {};165for (const [key, value] of Object.entries(raw)) {166if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;167}168return out;169}170171export function normalizeIgnoreValue(value) {172return String(value || '')173.trim()174.replace(/^["']|["']$/g, '')175.replace(/\+/g, ' ')176.replace(/\s+/g, ' ')177.toLowerCase();178}179180function normalizeIgnoreRule(rule) {181return String(rule || '').trim().toLowerCase();182}183184function colorIgnoreKey(value) {185const color = parseIgnoreColor(value);186if (!color) return '';187return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;188}189190function parseIgnoreColor(value) {191const text = String(value || '').trim().toLowerCase();192if (!text) return null;193194const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);195if (hex) return parseHexIgnoreColor(hex[1]);196197const rgb = text.match(/^rgba?\((.*)\)$/i);198if (rgb) {199const parts = splitColorArgs(rgb[1]);200if (parts.length < 3 || parts.length > 4) return null;201const r = parseRgbChannel(parts[0]);202const g = parseRgbChannel(parts[1]);203const b = parseRgbChannel(parts[2]);204const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);205if ([r, g, b, a].some((v) => v === null)) return null;206return { r, g, b, a };207}208209const hsl = text.match(/^hsla?\((.*)\)$/i);210if (hsl) {211const parts = splitColorArgs(hsl[1]);212if (parts.length < 3 || parts.length > 4) return null;213const h = parseHueChannel(parts[0]);214const s = parsePercentChannel(parts[1]);215const l = parsePercentChannel(parts[2]);216const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);217if ([h, s, l, a].some((v) => v === null)) return null;218return hslToRgb(h, s, l, a);219}220221return null;222}223224function parseHexIgnoreColor(hex) {225if (hex.length === 3 || hex.length === 4) {226const r = parseInt(hex[0] + hex[0], 16);227const g = parseInt(hex[1] + hex[1], 16);228const b = parseInt(hex[2] + hex[2], 16);229const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;230return { r, g, b, a };231}232const r = parseInt(hex.slice(0, 2), 16);233const g = parseInt(hex.slice(2, 4), 16);234const b = parseInt(hex.slice(4, 6), 16);235const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;236return { r, g, b, a };237}238239function splitColorArgs(body) {240const text = String(body || '').trim();241if (!text) return [];242if (text.includes(',')) {243const parts = text.split(',').map((part) => part.trim()).filter(Boolean);244const last = parts[parts.length - 1];245if (last && last.includes('/')) {246const split = last.split('/').map((part) => part.trim()).filter(Boolean);247return [...parts.slice(0, -1), ...split];248}249return parts;250}251return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');252}253254function parseRgbChannel(raw) {255const text = String(raw || '').trim();256const match = text.match(/^(-?\d*\.?\d+)(%)?$/);257if (!match) return null;258const value = Number.parseFloat(match[1]);259if (!Number.isFinite(value)) return null;260const scaled = match[2] ? value * 2.55 : value;261if (scaled < 0 || scaled > 255) return null;262return Math.round(scaled);263}264265function parseAlphaChannel(raw) {266const text = String(raw || '').trim();267const match = text.match(/^(-?\d*\.?\d+)(%)?$/);268if (!match) return null;269const value = Number.parseFloat(match[1]);270if (!Number.isFinite(value)) return null;271const alpha = match[2] ? value / 100 : value;272return alpha >= 0 && alpha <= 1 ? alpha : null;273}274275function parseHueChannel(raw) {276const text = String(raw || '').trim();277const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);278if (!match) return null;279const value = Number.parseFloat(match[1]);280if (!Number.isFinite(value)) return null;281const unit = match[2] || 'deg';282if (unit === 'turn') return value * 360;283if (unit === 'rad') return value * (180 / Math.PI);284if (unit === 'grad') return value * 0.9;285return value;286}287288function parsePercentChannel(raw) {289const text = String(raw || '').trim();290const match = text.match(/^(-?\d*\.?\d+)%$/);291if (!match) return null;292const value = Number.parseFloat(match[1]);293if (!Number.isFinite(value)) return null;294return value >= 0 && value <= 100 ? value / 100 : null;295}296297function hslToRgb(hue, saturation, lightness, alpha) {298const h = (((hue % 360) + 360) % 360) / 360;299if (saturation === 0) {300const gray = clampByte(Math.round(lightness * 255));301return { r: gray, g: gray, b: gray, a: alpha };302}303const q = lightness < 0.5304? lightness * (1 + saturation)305: lightness + saturation - lightness * saturation;306const p = 2 * lightness - q;307const toRgb = (t) => {308let channel = t;309if (channel < 0) channel += 1;310if (channel > 1) channel -= 1;311if (channel < 1 / 6) return p + (q - p) * 6 * channel;312if (channel < 1 / 2) return q;313if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;314return p;315};316return {317r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),318g: clampByte(Math.round(toRgb(h) * 255)),319b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),320a: alpha,321};322}323324function clampByte(value) {325return Math.min(255, Math.max(0, value));326}327328function ignoreValueMatches(rule, entryValue, findingValue) {329if (entryValue === findingValue) return true;330if (rule !== 'design-system-color') return false;331const entryColor = colorIgnoreKey(entryValue);332return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));333}334335export function normalizeIgnoreValueEntries(entries) {336if (!Array.isArray(entries)) return [];337const out = [];338for (const entry of entries) {339if (!entry || typeof entry !== 'object') continue;340const rule = normalizeIgnoreRule(entry.rule);341const value = normalizeIgnoreValue(entry.value);342if (!rule || !value) continue;343const normalized = { rule, value };344const files = uniqueStrings([345...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),346...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),347]);348if (files.length > 0) normalized.files = files;349if (typeof entry.reason === 'string' && entry.reason.trim()) {350normalized.reason = entry.reason.trim();351}352if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {353normalized.createdAt = entry.createdAt.trim();354}355out.push(normalized);356}357return out;358}359360function mergeIgnoreValues(existing, incoming) {361const map = new Map();362for (const entry of normalizeIgnoreValueEntries(existing)) {363map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);364}365for (const entry of normalizeIgnoreValueEntries(incoming)) {366map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);367}368return Array.from(map.values());369}370371function ignoreValueFilesKey(files) {372return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';373}374375// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.376function globToRegex(glob) {377let re = '^';378let i = 0;379while (i < glob.length) {380const c = glob[i];381if (c === '*') {382if (glob[i + 1] === '*') {383re += '.*';384i += 2;385if (glob[i] === '/') i += 1;386} else {387re += '[^/]*';388i += 1;389}390} else if (c === '?') {391re += '[^/]';392i += 1;393} else if (c === '{') {394const end = glob.indexOf('}', i);395if (end === -1) { re += '\\{'; i += 1; continue; }396const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));397re += `(?:${parts.join('|')})`;398i = end + 1;399} else if (/[.+^$()|[\]\\]/.test(c)) {400re += `\\${c}`;401i += 1;402} else {403re += c;404i += 1;405}406}407re += '$';408return new RegExp(re);409}410411export function matchesAnyGlob(filePath, globs) {412if (!Array.isArray(globs) || globs.length === 0) return false;413const normalized = String(filePath || '').split(sep).join('/');414for (const glob of globs) {415try {416const re = globToRegex(String(glob));417if (re.test(normalized)) return true;418const base = normalized.split('/').pop();419if (re.test(base)) return true;420} catch {421/* malformed glob, skip */422}423}424return false;425}426427export function shouldIgnoreDetectionFile(filePath, root, config) {428const globs = config?.ignoreFiles || [];429if (!Array.isArray(globs) || globs.length === 0) return false;430const raw = String(filePath || '').trim();431if (!raw) return false;432if (matchesAnyGlob(raw, globs)) return true;433434try {435const abs = isAbsolute(raw) ? raw : resolve(root, raw);436if (matchesAnyGlob(abs, globs)) return true;437const rel = relative(root, abs);438if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {439return matchesAnyGlob(rel, globs);440}441} catch {442/* ignore */443}444return false;445}446447export function filterDetectionFindings(findings, config) {448if (!Array.isArray(findings) || findings.length === 0) return [];449const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));450const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);451return findings.filter((finding) => {452if (!finding || typeof finding !== 'object') return false;453if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;454if (isIgnoredFindingValue(finding, ignoreValues)) return false;455return true;456});457}458459function isIgnoredFindingValue(finding, ignoreValues) {460if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;461const rule = normalizeIgnoreRule(finding.antipattern);462const value = extractFindingIgnoreValue(finding);463if (!rule || !value) return false;464return ignoreValues.some((entry) => {465const wildcardValue = entry.value === '*';466if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;467if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;468return findingMatchesScopedIgnoreFile(finding, entry.files);469});470}471472function findingMatchesScopedIgnoreFile(finding, globs) {473const filePath = String(finding?.file || '').trim();474if (!filePath) return false;475if (matchesAnyGlob(filePath, globs)) return true;476477const normalized = filePath.split(sep).join('/');478const parts = normalized.split('/').filter(Boolean);479for (let i = 0; i < parts.length; i++) {480const suffix = parts.slice(i).join('/');481if (matchesAnyGlob(suffix, globs)) return true;482}483return false;484}485486export function extractFindingIgnoreValue(finding) {487if (!finding || typeof finding !== 'object') return '';488const rule = normalizeIgnoreRule(finding.antipattern);489const directValueRules = new Set([490'overused-font',491'bounce-easing',492'design-system-font',493'design-system-color',494'design-system-radius',495]);496if (!directValueRules.has(rule)) return '';497return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));498}499500function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {501const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');502if (direct) return direct;503504const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);505for (const text of candidates) {506if (rule === 'bounce-easing') {507const motion = extractMotionIgnoreValue(text);508if (motion) return motion;509continue;510}511512const primary = text.match(/Primary font:\s*([^()\n;]+)/i);513if (primary) return cleanIgnoreValueDisplay(primary[1]);514515const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);516if (family) return cleanIgnoreValueDisplay(family[1]);517518const google = text.match(/[?&]family=([^&:;\n]+)/i);519if (google) {520try {521return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));522} catch {523return cleanIgnoreValueDisplay(google[1]);524}525}526}527528return '';529}530531function extractMotionIgnoreValue(text) {532const tailwind = text.match(/\banimate-bounce\b/i);533if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);534535const bezier = text.match(/cubic-bezier\([^)]+\)/i);536if (bezier) return cleanIgnoreValueDisplay(bezier[0]);537538const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);539if (animation) {540const token = animation[1]541.split(/[,\s]+/)542.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));543if (token) return cleanIgnoreValueDisplay(token);544}545546return '';547}548549function cleanIgnoreValueDisplay(value) {550return String(value || '')551.trim()552.replace(/^["']|["']$/g, '')553.replace(/\+/g, ' ')554.replace(/\s+/g, ' ');555}556557/**558* The recorded design-hook decision: 'accepted' | 'declined' | undefined.559* config.local.json (per-developer) overrides config.json.560*/561export function getHookConsent(root) {562let consent;563for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {564const hook = hookSection(safeReadJson(filePath));565if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;566}567return consent;568}569570/**571* Persist the per-developer decision to config.local.json, preserving any572* sibling keys, and ensure the file is gitignored.573*/574export function setHookConsent(root, value) {575const filePath = getLocalConfigPath(root);576const existing = safeReadJson(filePath) || {};577const hook = hookSection(existing) || {};578const next = { ...existing, hook: { ...hook, consent: value } };579mkdirSync(dirname(filePath), { recursive: true });580writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);581ensureConfigGitExclude(root);582return filePath;583}584585const EXCLUDE_OPEN = '# impeccable-config-ignore-start';586const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';587const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];588589/**590* Add config.local.json to `.git/info/exclude` so a developer's decision is591* never committed. Idempotent via marker comments. Best-effort; returns false592* when there is no resolvable git dir.593*/594export function ensureConfigGitExclude(root) {595try {596const gitDir = resolveGitDir(root);597if (!gitDir) return false;598const target = join(gitDir, 'info', 'exclude');599const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';600const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');601const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);602let updated;603if (markerRe.test(existing)) {604updated = existing.replace(markerRe, block);605} else {606const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;607updated = `${prefix}${block}\n`;608}609if (updated !== existing) {610mkdirSync(dirname(target), { recursive: true });611writeFileSync(target, updated);612}613return true;614} catch {615return false;616}617}618619function resolveGitDir(root) {620const dotGit = join(root, '.git');621if (!existsSync(dotGit)) return null;622try {623if (statSync(dotGit).isDirectory()) return dotGit;624// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".625const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);626if (match) {627const resolved = match[1].trim();628return isAbsolute(resolved) ? resolved : join(root, resolved);629}630} catch {631/* fall through */632}633return null;634}635636function escapeRegExp(value) {637return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');638}639