641 lines
21 KiB
JavaScript
641 lines
21 KiB
JavaScript
/**
|
|
* CLI-side reader/writer for the unified `.impeccable` config.
|
|
*
|
|
* The CLI (published to npm) and the skill scripts (bundled into the install)
|
|
* live in separate trees and cannot share runtime code, so this duplicates a
|
|
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
|
|
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
|
|
* ignore filtering, and exclude marker in sync if either side changes.
|
|
*
|
|
* Schema (config.json shared / config.local.json gitignored, per-developer):
|
|
* {
|
|
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
|
|
* "hook": { "consent": "accepted" | "declined", ... },
|
|
* "updateCheck": bool
|
|
* }
|
|
*/
|
|
|
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
|
|
export function getConfigPath(root) {
|
|
return join(root, '.impeccable', 'config.json');
|
|
}
|
|
|
|
export function getLocalConfigPath(root) {
|
|
return join(root, '.impeccable', 'config.local.json');
|
|
}
|
|
|
|
function safeReadJson(filePath) {
|
|
try {
|
|
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function hookSection(raw) {
|
|
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
|
}
|
|
|
|
function detectorSection(raw) {
|
|
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
|
}
|
|
|
|
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
|
|
|
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
|
ignoreRules: [],
|
|
ignoreFiles: [],
|
|
ignoreValues: [],
|
|
designSystem: { enabled: true },
|
|
});
|
|
|
|
function cloneDetectionConfig() {
|
|
return {
|
|
ignoreRules: [],
|
|
ignoreFiles: [],
|
|
ignoreValues: [],
|
|
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
|
|
};
|
|
}
|
|
|
|
function cloneRawDetectionConfig() {
|
|
return {
|
|
ignoreRules: [],
|
|
ignoreFiles: [],
|
|
ignoreValues: [],
|
|
};
|
|
}
|
|
|
|
function applyDetectionConfigSource(config, raw) {
|
|
if (!raw || typeof raw !== 'object') return config;
|
|
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
|
config.designSystem = {
|
|
...config.designSystem,
|
|
enabled: raw.designSystem.enabled === false ? false : true,
|
|
};
|
|
}
|
|
if (Array.isArray(raw.ignoreRules)) {
|
|
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
|
}
|
|
if (Array.isArray(raw.ignoreFiles)) {
|
|
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
|
|
}
|
|
if (Array.isArray(raw.ignoreValues)) {
|
|
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
|
}
|
|
return config;
|
|
}
|
|
|
|
function uniqueStrings(values) {
|
|
return Array.from(new Set(values.map(String)));
|
|
}
|
|
|
|
/**
|
|
* Detector filters shared by `npx impeccable detect` and the design hook.
|
|
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
|
|
* the hook is disabled, but they honor the same ignore rules and design-system
|
|
* toggle.
|
|
*/
|
|
export function readDetectionConfig(root) {
|
|
const config = cloneDetectionConfig();
|
|
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
|
const raw = safeReadJson(filePath);
|
|
// Back-compat: old builds stored detector filters under hook.*.
|
|
applyDetectionConfigSource(config, hookSection(raw));
|
|
applyDetectionConfigSource(config, detectorSection(raw));
|
|
}
|
|
return config;
|
|
}
|
|
|
|
export function readRawDetectionConfig(root, opts = {}) {
|
|
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
|
|
const config = cloneRawDetectionConfig();
|
|
applyDetectionConfigSource(config, hookSection(raw));
|
|
applyDetectionConfigSource(config, detectorSection(raw));
|
|
return config;
|
|
}
|
|
|
|
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
|
|
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
|
|
if (opts.local) ensureConfigGitExclude(root);
|
|
const existing = safeReadJson(filePath) || {};
|
|
const existingHook = hookSection(existing);
|
|
const nextHook = stripDetectorKeys(existingHook);
|
|
const nextDetector = {
|
|
...(detectorSection(existing) || {}),
|
|
...normalizeDetectionConfigForWrite(detectorConfig),
|
|
};
|
|
const next = {
|
|
...existing,
|
|
detector: nextDetector,
|
|
};
|
|
if (nextHook && Object.keys(nextHook).length > 0) {
|
|
next.hook = nextHook;
|
|
} else {
|
|
delete next.hook;
|
|
}
|
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
|
return filePath;
|
|
}
|
|
|
|
function normalizeDetectionConfigForWrite(config) {
|
|
const out = {};
|
|
if (Array.isArray(config?.ignoreRules)) {
|
|
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
|
|
}
|
|
if (Array.isArray(config?.ignoreFiles)) {
|
|
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
|
}
|
|
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
|
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
|
out.designSystem = {
|
|
enabled: config.designSystem.enabled === false ? false : true,
|
|
};
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function stripDetectorKeys(raw) {
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
const out = {};
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function normalizeIgnoreValue(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.replace(/^["']|["']$/g, '')
|
|
.replace(/\+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.toLowerCase();
|
|
}
|
|
|
|
function normalizeIgnoreRule(rule) {
|
|
return String(rule || '').trim().toLowerCase();
|
|
}
|
|
|
|
function colorIgnoreKey(value) {
|
|
const color = parseIgnoreColor(value);
|
|
if (!color) return '';
|
|
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
|
|
}
|
|
|
|
function parseIgnoreColor(value) {
|
|
const text = String(value || '').trim().toLowerCase();
|
|
if (!text) return null;
|
|
|
|
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
|
if (hex) return parseHexIgnoreColor(hex[1]);
|
|
|
|
const rgb = text.match(/^rgba?\((.*)\)$/i);
|
|
if (rgb) {
|
|
const parts = splitColorArgs(rgb[1]);
|
|
if (parts.length < 3 || parts.length > 4) return null;
|
|
const r = parseRgbChannel(parts[0]);
|
|
const g = parseRgbChannel(parts[1]);
|
|
const b = parseRgbChannel(parts[2]);
|
|
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
|
if ([r, g, b, a].some((v) => v === null)) return null;
|
|
return { r, g, b, a };
|
|
}
|
|
|
|
const hsl = text.match(/^hsla?\((.*)\)$/i);
|
|
if (hsl) {
|
|
const parts = splitColorArgs(hsl[1]);
|
|
if (parts.length < 3 || parts.length > 4) return null;
|
|
const h = parseHueChannel(parts[0]);
|
|
const s = parsePercentChannel(parts[1]);
|
|
const l = parsePercentChannel(parts[2]);
|
|
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
|
if ([h, s, l, a].some((v) => v === null)) return null;
|
|
return hslToRgb(h, s, l, a);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseHexIgnoreColor(hex) {
|
|
if (hex.length === 3 || hex.length === 4) {
|
|
const r = parseInt(hex[0] + hex[0], 16);
|
|
const g = parseInt(hex[1] + hex[1], 16);
|
|
const b = parseInt(hex[2] + hex[2], 16);
|
|
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
|
return { r, g, b, a };
|
|
}
|
|
const r = parseInt(hex.slice(0, 2), 16);
|
|
const g = parseInt(hex.slice(2, 4), 16);
|
|
const b = parseInt(hex.slice(4, 6), 16);
|
|
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
|
return { r, g, b, a };
|
|
}
|
|
|
|
function splitColorArgs(body) {
|
|
const text = String(body || '').trim();
|
|
if (!text) return [];
|
|
if (text.includes(',')) {
|
|
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
|
|
const last = parts[parts.length - 1];
|
|
if (last && last.includes('/')) {
|
|
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
|
|
return [...parts.slice(0, -1), ...split];
|
|
}
|
|
return parts;
|
|
}
|
|
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
|
}
|
|
|
|
function parseRgbChannel(raw) {
|
|
const text = String(raw || '').trim();
|
|
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
|
if (!match) return null;
|
|
const value = Number.parseFloat(match[1]);
|
|
if (!Number.isFinite(value)) return null;
|
|
const scaled = match[2] ? value * 2.55 : value;
|
|
if (scaled < 0 || scaled > 255) return null;
|
|
return Math.round(scaled);
|
|
}
|
|
|
|
function parseAlphaChannel(raw) {
|
|
const text = String(raw || '').trim();
|
|
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
|
if (!match) return null;
|
|
const value = Number.parseFloat(match[1]);
|
|
if (!Number.isFinite(value)) return null;
|
|
const alpha = match[2] ? value / 100 : value;
|
|
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
|
}
|
|
|
|
function parseHueChannel(raw) {
|
|
const text = String(raw || '').trim();
|
|
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
|
if (!match) return null;
|
|
const value = Number.parseFloat(match[1]);
|
|
if (!Number.isFinite(value)) return null;
|
|
const unit = match[2] || 'deg';
|
|
if (unit === 'turn') return value * 360;
|
|
if (unit === 'rad') return value * (180 / Math.PI);
|
|
if (unit === 'grad') return value * 0.9;
|
|
return value;
|
|
}
|
|
|
|
function parsePercentChannel(raw) {
|
|
const text = String(raw || '').trim();
|
|
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
|
if (!match) return null;
|
|
const value = Number.parseFloat(match[1]);
|
|
if (!Number.isFinite(value)) return null;
|
|
return value >= 0 && value <= 100 ? value / 100 : null;
|
|
}
|
|
|
|
function hslToRgb(hue, saturation, lightness, alpha) {
|
|
const h = (((hue % 360) + 360) % 360) / 360;
|
|
if (saturation === 0) {
|
|
const gray = clampByte(Math.round(lightness * 255));
|
|
return { r: gray, g: gray, b: gray, a: alpha };
|
|
}
|
|
const q = lightness < 0.5
|
|
? lightness * (1 + saturation)
|
|
: lightness + saturation - lightness * saturation;
|
|
const p = 2 * lightness - q;
|
|
const toRgb = (t) => {
|
|
let channel = t;
|
|
if (channel < 0) channel += 1;
|
|
if (channel > 1) channel -= 1;
|
|
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
|
if (channel < 1 / 2) return q;
|
|
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
|
return p;
|
|
};
|
|
return {
|
|
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
|
|
g: clampByte(Math.round(toRgb(h) * 255)),
|
|
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
|
|
a: alpha,
|
|
};
|
|
}
|
|
|
|
function clampByte(value) {
|
|
return Math.min(255, Math.max(0, value));
|
|
}
|
|
|
|
function ignoreValueMatches(rule, entryValue, findingValue) {
|
|
if (entryValue === findingValue) return true;
|
|
if (rule !== 'design-system-color') return false;
|
|
const entryColor = colorIgnoreKey(entryValue);
|
|
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
|
|
}
|
|
|
|
export function normalizeIgnoreValueEntries(entries) {
|
|
if (!Array.isArray(entries)) return [];
|
|
const out = [];
|
|
for (const entry of entries) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const rule = normalizeIgnoreRule(entry.rule);
|
|
const value = normalizeIgnoreValue(entry.value);
|
|
if (!rule || !value) continue;
|
|
const normalized = { rule, value };
|
|
const files = uniqueStrings([
|
|
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
|
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
|
]);
|
|
if (files.length > 0) normalized.files = files;
|
|
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
|
normalized.reason = entry.reason.trim();
|
|
}
|
|
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
|
normalized.createdAt = entry.createdAt.trim();
|
|
}
|
|
out.push(normalized);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function mergeIgnoreValues(existing, incoming) {
|
|
const map = new Map();
|
|
for (const entry of normalizeIgnoreValueEntries(existing)) {
|
|
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
|
}
|
|
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
|
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
|
}
|
|
return Array.from(map.values());
|
|
}
|
|
|
|
function ignoreValueFilesKey(files) {
|
|
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
|
|
}
|
|
|
|
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
|
function globToRegex(glob) {
|
|
let re = '^';
|
|
let i = 0;
|
|
while (i < glob.length) {
|
|
const c = glob[i];
|
|
if (c === '*') {
|
|
if (glob[i + 1] === '*') {
|
|
re += '.*';
|
|
i += 2;
|
|
if (glob[i] === '/') i += 1;
|
|
} else {
|
|
re += '[^/]*';
|
|
i += 1;
|
|
}
|
|
} else if (c === '?') {
|
|
re += '[^/]';
|
|
i += 1;
|
|
} else if (c === '{') {
|
|
const end = glob.indexOf('}', i);
|
|
if (end === -1) { re += '\\{'; i += 1; continue; }
|
|
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
|
re += `(?:${parts.join('|')})`;
|
|
i = end + 1;
|
|
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
|
re += `\\${c}`;
|
|
i += 1;
|
|
} else {
|
|
re += c;
|
|
i += 1;
|
|
}
|
|
}
|
|
re += '$';
|
|
return new RegExp(re);
|
|
}
|
|
|
|
export function matchesAnyGlob(filePath, globs) {
|
|
if (!Array.isArray(globs) || globs.length === 0) return false;
|
|
const normalized = String(filePath || '').split(sep).join('/');
|
|
for (const glob of globs) {
|
|
try {
|
|
const re = globToRegex(String(glob));
|
|
if (re.test(normalized)) return true;
|
|
const base = normalized.split('/').pop();
|
|
if (re.test(base)) return true;
|
|
} catch {
|
|
/* malformed glob, skip */
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function shouldIgnoreDetectionFile(filePath, root, config) {
|
|
const globs = config?.ignoreFiles || [];
|
|
if (!Array.isArray(globs) || globs.length === 0) return false;
|
|
const raw = String(filePath || '').trim();
|
|
if (!raw) return false;
|
|
if (matchesAnyGlob(raw, globs)) return true;
|
|
|
|
try {
|
|
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
|
|
if (matchesAnyGlob(abs, globs)) return true;
|
|
const rel = relative(root, abs);
|
|
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
|
return matchesAnyGlob(rel, globs);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function filterDetectionFindings(findings, config) {
|
|
if (!Array.isArray(findings) || findings.length === 0) return [];
|
|
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
|
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
|
return findings.filter((finding) => {
|
|
if (!finding || typeof finding !== 'object') return false;
|
|
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
|
|
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function isIgnoredFindingValue(finding, ignoreValues) {
|
|
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
|
const rule = normalizeIgnoreRule(finding.antipattern);
|
|
if (!rule) return false;
|
|
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
|
|
const value = extractFindingIgnoreValue(finding);
|
|
return ignoreValues.some((entry) => {
|
|
if (entry.rule !== rule) return false;
|
|
const wildcardValue = entry.value === '*';
|
|
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
|
|
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
|
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
|
});
|
|
}
|
|
|
|
function findingMatchesScopedIgnoreFile(finding, globs) {
|
|
const filePath = String(finding?.file || '').trim();
|
|
if (!filePath) return false;
|
|
if (matchesAnyGlob(filePath, globs)) return true;
|
|
|
|
const normalized = filePath.split(sep).join('/');
|
|
const parts = normalized.split('/').filter(Boolean);
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const suffix = parts.slice(i).join('/');
|
|
if (matchesAnyGlob(suffix, globs)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function extractFindingIgnoreValue(finding) {
|
|
if (!finding || typeof finding !== 'object') return '';
|
|
const rule = normalizeIgnoreRule(finding.antipattern);
|
|
const directValueRules = new Set([
|
|
'overused-font',
|
|
'bounce-easing',
|
|
'design-system-font',
|
|
'design-system-color',
|
|
'design-system-radius',
|
|
]);
|
|
if (!directValueRules.has(rule)) return '';
|
|
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
|
}
|
|
|
|
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
|
|
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
|
|
if (direct) return direct;
|
|
|
|
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
|
|
for (const text of candidates) {
|
|
if (rule === 'bounce-easing') {
|
|
const motion = extractMotionIgnoreValue(text);
|
|
if (motion) return motion;
|
|
continue;
|
|
}
|
|
|
|
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
|
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
|
|
|
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
|
if (family) return cleanIgnoreValueDisplay(family[1]);
|
|
|
|
const google = text.match(/[?&]family=([^&:;\n]+)/i);
|
|
if (google) {
|
|
try {
|
|
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
|
|
} catch {
|
|
return cleanIgnoreValueDisplay(google[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function extractMotionIgnoreValue(text) {
|
|
const tailwind = text.match(/\banimate-bounce\b/i);
|
|
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
|
|
|
|
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
|
|
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
|
|
|
|
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
|
|
if (animation) {
|
|
const token = animation[1]
|
|
.split(/[,\s]+/)
|
|
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
|
if (token) return cleanIgnoreValueDisplay(token);
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function cleanIgnoreValueDisplay(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.replace(/^["']|["']$/g, '')
|
|
.replace(/\+/g, ' ')
|
|
.replace(/\s+/g, ' ');
|
|
}
|
|
|
|
/**
|
|
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
|
|
* config.local.json (per-developer) overrides config.json.
|
|
*/
|
|
export function getHookConsent(root) {
|
|
let consent;
|
|
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
|
const hook = hookSection(safeReadJson(filePath));
|
|
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
|
|
}
|
|
return consent;
|
|
}
|
|
|
|
/**
|
|
* Persist the per-developer decision to config.local.json, preserving any
|
|
* sibling keys, and ensure the file is gitignored.
|
|
*/
|
|
export function setHookConsent(root, value) {
|
|
const filePath = getLocalConfigPath(root);
|
|
const existing = safeReadJson(filePath) || {};
|
|
const hook = hookSection(existing) || {};
|
|
const next = { ...existing, hook: { ...hook, consent: value } };
|
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
|
ensureConfigGitExclude(root);
|
|
return filePath;
|
|
}
|
|
|
|
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
|
|
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
|
|
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
|
|
|
|
/**
|
|
* Add config.local.json to `.git/info/exclude` so a developer's decision is
|
|
* never committed. Idempotent via marker comments. Best-effort; returns false
|
|
* when there is no resolvable git dir.
|
|
*/
|
|
export function ensureConfigGitExclude(root) {
|
|
try {
|
|
const gitDir = resolveGitDir(root);
|
|
if (!gitDir) return false;
|
|
const target = join(gitDir, 'info', 'exclude');
|
|
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
|
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
|
|
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
|
|
let updated;
|
|
if (markerRe.test(existing)) {
|
|
updated = existing.replace(markerRe, block);
|
|
} else {
|
|
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
|
updated = `${prefix}${block}\n`;
|
|
}
|
|
if (updated !== existing) {
|
|
mkdirSync(dirname(target), { recursive: true });
|
|
writeFileSync(target, updated);
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function resolveGitDir(root) {
|
|
const dotGit = join(root, '.git');
|
|
if (!existsSync(dotGit)) return null;
|
|
try {
|
|
if (statSync(dotGit).isDirectory()) return dotGit;
|
|
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
|
|
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
|
|
if (match) {
|
|
const resolved = match[1].trim();
|
|
return isAbsolute(resolved) ? resolved : join(root, resolved);
|
|
}
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|