[codex] Add design-aware detector rules (#252)

* Add design-aware detector rules

* Fix design-aware detector noise

* Unify CLI and hook detector ignores

* Fix remaining design-system review findings

* Add detector ignore CLI

* Fix design detector review findings

* Fix design color source false positives

* Fix core test suite registration

* Add design-aware detector docs

* Fix font priority design-system parsing

* Fix color ignore value matching
This commit is contained in:
Paul Bakaus
2026-06-15 21:06:17 -07:00
committed by GitHub
parent c27a75ad41
commit 51d01e3a5f
80 changed files with 5372 additions and 527 deletions
+111 -49
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` — manage the design hook
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
* `/impeccable hooks <on|off|status|reset>` — manage the design hook runtime
* via the `hook` key and shared detector ignores via the `detector` key in
* .impeccable/config.json / .impeccable/config.local.json.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -120,23 +120,48 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
function hookSection(unified) {
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
? unified.hook
: null;
}
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
function detectorSection(unified) {
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.detector && typeof unified.detector === 'object' && !Array.isArray(unified.detector)
? unified.detector
: null;
}
function readRawHookConfig(cwd, opts = {}) {
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
return hookSection(unified);
}
function readRawDetectorConfig(cwd, opts = {}) {
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
const merged = mergeDetectorConfig(hookSection(unified));
return mergeDetectorConfig(detectorSection(unified), merged);
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
// Write hook runtime config under `hook`, leaving detector filters in
// `detector` and preserving sibling keys such as updateCheck.
function writeHookConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
const existingHook = stripDetectorKeys(hookSection(existing));
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
@@ -145,15 +170,28 @@ function writeConfig(cwd, hookConfig, opts = {}) {
return filePath;
}
function mergeConfig(existing) {
// Persist the full shape so /impeccable hooks edits leave a complete file
// for the user to see, not an unhelpful `{"enabled":false}`.
function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const nextHook = stripDetectorKeys(hookSection(existing));
const existingDetector = mergeDetectorConfig(detectorSection(existing));
const next = {
...existing,
detector: mergeDetectorConfig(detectorConfig, existingDetector),
};
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
else delete next.hook;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
function mergeHookConfig(existing) {
const base = existing && typeof existing === 'object' ? existing : {};
return {
enabled: base.enabled === false ? false : true,
ignoreRules: Array.isArray(base.ignoreRules) ? Array.from(new Set(base.ignoreRules.map(String))) : [],
ignoreFiles: Array.isArray(base.ignoreFiles) ? Array.from(new Set(base.ignoreFiles.map(String))) : [],
ignoreValues: normalizeIgnoreValueEntries(base.ignoreValues || []),
limits: {
maxFindings: Number.isFinite(base?.limits?.maxFindings) ? base.limits.maxFindings : DEFAULT_CONFIG.limits.maxFindings,
maxChars: Number.isFinite(base?.limits?.maxChars) ? base.limits.maxChars : DEFAULT_CONFIG.limits.maxChars,
@@ -161,28 +199,54 @@ function mergeConfig(existing) {
};
}
function mergeLocalConfig(existing) {
function mergeDetectorConfig(existing, seed = null) {
const base = existing && typeof existing === 'object' ? existing : {};
const out = {};
if (Object.prototype.hasOwnProperty.call(base, 'enabled')) {
out.enabled = base.enabled === false ? false : true;
const out = seed ? {
ignoreRules: [...seed.ignoreRules],
ignoreFiles: [...seed.ignoreFiles],
ignoreValues: normalizeIgnoreValueEntries(seed.ignoreValues),
} : {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
out.designSystem = { ...seed.designSystem };
}
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
out.designSystem = {
...(out.designSystem || {}),
enabled: base.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(base.ignoreRules)) {
out.ignoreRules = Array.from(new Set(base.ignoreRules.map(String)));
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
}
if (Array.isArray(base.ignoreFiles)) {
out.ignoreFiles = Array.from(new Set(base.ignoreFiles.map(String)));
out.ignoreFiles = Array.from(new Set([...out.ignoreFiles, ...base.ignoreFiles.map(String)]));
}
out.ignoreValues = normalizeIgnoreValueEntries(base.ignoreValues || []);
if (base.limits && typeof base.limits === 'object') {
const limits = {};
if (Number.isFinite(base.limits.maxFindings)) limits.maxFindings = base.limits.maxFindings;
if (Number.isFinite(base.limits.maxChars)) limits.maxChars = base.limits.maxChars;
if (Object.keys(limits).length) out.limits = limits;
if (Array.isArray(base.ignoreValues)) {
out.ignoreValues = mergeIgnoreValueEntries(out.ignoreValues, base.ignoreValues);
}
return out;
}
function mergeIgnoreValueEntries(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(ignoreValueEntryKey(entry), entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(ignoreValueEntryKey(entry), entry);
}
return Array.from(map.values());
}
function ignoreValueEntryKey(entry) {
const files = Array.isArray(entry.files) && entry.files.length > 0 ? entry.files.join('\x1f') : '';
return `${entry.rule}\0${entry.value}\0${files}`;
}
function statusReport(cwd) {
const shared = readRawConfigFile(getConfigPath(cwd));
const local = readRawConfigFile(getLocalConfigPath(cwd));
@@ -216,14 +280,14 @@ function statusReport(cwd) {
}
function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
const config = mergeHookConfig(readRawHookConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
const target = writeHookConfig(cwd, config);
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const localTarget = writeHookConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
@@ -429,18 +493,18 @@ function addIgnoreRule(cwd, args) {
if (rule === 'overused-font' && !parsed.allValues) {
throw new Error('overused-font is value-specific by default. Use /impeccable hooks ignore-value overused-font <font> for a confirmed font, or /impeccable hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.');
}
const config = mergeConfig(readRawConfig(cwd));
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
writeConfig(cwd, config);
return `Added "${rule}" to ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
writeDetectorConfig(cwd, config);
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
}
function addIgnoreFile(cwd, glob) {
if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"');
const config = mergeConfig(readRawConfig(cwd));
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
writeConfig(cwd, config);
return `Added "${glob}" to ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
writeDetectorConfig(cwd, config);
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
}
function parseIgnoreValueArgs(args) {
@@ -489,9 +553,7 @@ function addIgnoreValue(cwd, args) {
}
const local = parsed.local;
const config = local
? mergeLocalConfig(readRawConfig(cwd, { local: true }))
: mergeConfig(readRawConfig(cwd, { local: false }));
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
const key = `${parsed.rule}\0${parsed.value}`;
const existing = config.ignoreValues.find((entry) => `${entry.rule}\0${entry.value}` === key);
@@ -507,20 +569,20 @@ function addIgnoreValue(cwd, args) {
config.ignoreValues.push(entry);
}
const target = writeConfig(cwd, config, { local });
const scope = local ? 'local ignoreValues' : 'shared ignoreValues';
const target = writeDetectorConfig(cwd, config, { local });
const scope = local ? 'local detector.ignoreValues' : 'shared detector.ignoreValues';
return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`;
}
function reset(cwd) {
const removed = [];
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
// hook/detector subtrees and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || (!('hook' in raw) && !('detector' in raw))) continue;
const { hook, detector, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
+5 -2
View File
@@ -18,6 +18,8 @@ import {
EDIT_COUNT_THRESHOLD,
GENERATED_PATH,
SENSITIVE_PATH,
appendDesignSystemNote,
designSystemOptions,
filterFindings,
loadDetector,
matchesAnyGlob,
@@ -415,10 +417,11 @@ async function main() {
if (!detector || typeof detector.detectText !== 'function') {
return allow({ ...audit, skipped: 'detector-missing', durationMs: Date.now() - started });
}
const scanOptions = designSystemOptions(config, detector, cwd);
let findings = [];
try {
findings = await detector.detectText(content, filePath);
findings = await detector.detectText(content, filePath, scanOptions);
} catch {
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
}
@@ -433,7 +436,7 @@ async function main() {
});
}
const message = cursorBlockMessage(filtered, filePath, config, cwd);
const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions);
const sessionId = event.session_id || event.conversation_id || 'unknown';
const cache = readCache(cwd);
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
+265 -25
View File
@@ -73,6 +73,7 @@ export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
designSystem: { enabled: true },
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -135,10 +136,14 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
// Hook runtime settings live under `hook`; detector filters live under
// `detector`. Back-compat: older configs stored detector filters in `hook`,
// so read those first and let canonical `detector` settings win.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
const raw = safeReadJson(filePath);
applyConfigSource(config, hookSection(raw));
applyDetectorConfigSource(config, detectorSection(raw));
}
return config;
}
@@ -148,6 +153,11 @@ function hookSection(raw) {
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function detectorSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -158,10 +168,31 @@ function cloneDefaultConfig() {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_CONFIG.designSystem },
limits: { ...DEFAULT_CONFIG.limits },
};
}
function applyDetectorConfigSource(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 applyConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
@@ -173,15 +204,7 @@ function applyConfigSource(config, raw) {
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
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);
}
applyDetectorConfigSource(config, raw);
if (raw.limits && typeof raw.limits === 'object') {
config.limits = {
maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings),
@@ -208,6 +231,157 @@ 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 = [];
@@ -217,6 +391,11 @@ export function normalizeIgnoreValueEntries(entries) {
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();
}
@@ -231,14 +410,18 @@ export function normalizeIgnoreValueEntries(entries) {
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}`, entry);
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}`, entry);
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') : '';
}
export function readCache(cwd) {
const raw = safeReadJson(getCachePath(cwd));
if (!raw || typeof raw !== 'object' || raw.version !== 1) {
@@ -447,13 +630,39 @@ function isIgnoredFindingValue(finding, ignoreValues) {
const rule = normalizeIgnoreRule(finding.antipattern);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => entry.rule === rule && entry.value === value);
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !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(path.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);
if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
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));
}
@@ -520,7 +729,7 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) {
const known = new Set(fileEntry.findings || []);
const fresh = [];
for (const f of findings) {
const key = `${f.antipattern}:${f.line || 0}`;
const key = findingCacheKey(f);
if (known.has(key)) continue;
known.add(key);
fresh.push(f);
@@ -531,11 +740,21 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) {
export function rememberFindings(cache, sessionId, filePath, findings) {
const fileEntry = ensureFile(cache, sessionId, filePath);
const known = new Set(fileEntry.findings || []);
for (const f of findings) known.add(`${f.antipattern}:${f.line || 0}`);
for (const f of findings) known.add(findingCacheKey(f));
fileEntry.findings = Array.from(known);
ensureSession(cache, sessionId).updatedAt = Date.now();
}
function findingCacheKey(finding) {
const line = finding?.line || 0;
const value = extractFindingIgnoreValue(finding);
if (line > 0 && value) return `${finding.antipattern}:${line}:${value}`;
if (line > 0) return `${finding.antipattern}:${line}`;
if (value) return `${finding.antipattern}:0:${value}`;
const snippet = String(finding?.snippet || '').trim().slice(0, 80);
return snippet ? `${finding.antipattern}:0:${snippet}` : `${finding.antipattern}:0`;
}
export function renderTemplate(findings, filePath, config, opts = {}) {
if (!Array.isArray(findings) || findings.length === 0) return '';
const limits = config?.limits || DEFAULT_CONFIG.limits;
@@ -942,7 +1161,11 @@ export async function loadDetector(candidates = DETECTOR_CANDIDATES) {
const found = candidates.find((c) => fs.existsSync(c));
if (!found) return null;
const mod = await import(pathToFileURL(found));
detectorCache = { detectText: mod.detectText, detectHtml: mod.detectHtml };
detectorCache = {
detectText: mod.detectText,
detectHtml: mod.detectHtml,
loadDesignSystemForCwd: mod.loadDesignSystemForCwd,
};
return detectorCache;
}
@@ -999,6 +1222,22 @@ export function shouldEmitAckForFile(filePath) {
return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());
}
export function designSystemOptions(config, detector, projectCwd) {
if (config?.designSystem?.enabled === false) return {};
if (!detector || typeof detector.loadDesignSystemForCwd !== 'function') return {};
try {
const designSystem = detector.loadDesignSystemForCwd(projectCwd);
return designSystem ? { designSystem } : {};
} catch {
return {};
}
}
export function appendDesignSystemNote(text, scanOptions) {
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the design-system sidecar.`;
}
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
@@ -1086,6 +1325,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
persistCache(projectCwd, cache);
return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
}
const scanOptions = designSystemOptions(config, det, projectCwd);
let pendingWinner = null;
let cleanWinner = null;
@@ -1143,9 +1383,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
let findings;
let detectorThrew = false;
if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') {
try { findings = await det.detectHtml(filePath); } catch { findings = []; detectorThrew = true; }
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
} else {
try { findings = await det.detectText(content, filePath); } catch { findings = []; detectorThrew = true; }
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
}
const filtered = filterFindings(findings || [], content, ext, config);
@@ -1176,7 +1416,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
if (freshGroups.length > 0) {
const firstGroup = freshGroups[0];
const text = renderGroupedTemplate(freshGroups, config, { cwd: projectCwd });
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
const allFindings = freshGroups.flatMap((group) => group.findings);
return {
exitCode: 0,
@@ -1208,7 +1448,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
}
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {
const text = renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd });
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
return {
exitCode: 0,
stdout: payload(text, 'PostToolUse', harness),
@@ -1242,7 +1482,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
}
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {
const text = renderCleanAck(cleanWinner.filePath, { cwd: projectCwd });
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
return {
exitCode: 0,
stdout: payload(text, 'PostToolUse', harness),
+8 -1
View File
@@ -62,7 +62,7 @@ function parseYamlSubset(yaml) {
stack.pop();
}
const key = content.slice(0, colonIdx).trim();
const key = unquoteYamlKey(content.slice(0, colonIdx).trim());
const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());
const parent = stack[stack.length - 1].obj;
@@ -93,6 +93,13 @@ function findTopLevelColon(s) {
return -1;
}
function unquoteYamlKey(key) {
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
return key.slice(1, -1);
}
return key;
}
function stripInlineYamlComment(s) {
let inQuote = null;
for (let i = 0; i < s.length; i++) {
+16 -16
View File
@@ -2681,12 +2681,12 @@
});
const check = el('span', {
fontSize: '15px', lineHeight: '1', flexShrink: '0',
color: 'oklch(45% 0.15 145)',
color: 'oklch(45% 0.18 145)',
});
check.textContent = '\u2713';
row.appendChild(check);
const label = el('span', {
fontSize: '12px', color: 'oklch(35% 0.1 145)', fontWeight: '600',
fontSize: '12px', color: 'oklch(49% 0.08 188)', fontWeight: '600',
});
label.textContent = 'Variant applied';
row.appendChild(label);
@@ -8192,7 +8192,7 @@ void main() {
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
const STEER_AWAIT_TIMEOUT_MS = 120000;
const AGENT_STATUS_POLL_MS = 5000;
const AGENT_DISCONNECTED_MARK = 'oklch(56% 0.032 82 / 0.78)';
const AGENT_DISCONNECTED_MARK = 'oklch(62% 0 0 / 0.78)';
const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect';
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
@@ -8259,8 +8259,8 @@ void main() {
// Neutral hairline for internal control borders / dividers (was a warm
// gold rule that read as muddy champagne edges on the pill / input / count).
hairline: 'oklch(92% 0 0 / 0.12)',
text: 'oklch(84% 0.035 82)',
textDim: 'oklch(63% 0.024 82)',
text: 'oklch(91% 0 0)',
textDim: 'oklch(72% 0 0)',
accent: C.brand,
accentSoft: C.brandSoft,
exitHover: 'oklch(58% 0.15 35 / 0.18)',
@@ -9064,9 +9064,9 @@ void main() {
'#' + PREFIX + '-page-chat[data-voice-listening="true"] { border-color: oklch(70% 0.12 188 / 0.45); }' +
'#' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: impeccable-voice-pulse 1.1s ease-in-out infinite; }' +
'@media (prefers-reduced-motion: reduce) { #' + PREFIX + '-page-chat-voice[data-listening="true"] svg { animation: none; opacity: 1; } }' +
'#' + PREFIX + '-page-chat-input::placeholder { color: oklch(63% 0.024 82); opacity: 1; }' +
'#' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0 0); opacity: 1; }' +
'#' + PREFIX + '-page-chat-input { caret-color: oklch(84% 0.19 80.46); }' +
'#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0.024 82); }' +
'#' + PREFIX + '-page-chat[data-input-focused="true"]:not([data-expanded="true"]) #' + PREFIX + '-page-chat-input::placeholder { color: oklch(72% 0 0); }' +
'#' + PREFIX + '-page-chat-voice:hover { background: oklch(78% 0.12 82 / 0.12); }';
uiAppendStyle(s);
}
@@ -9306,7 +9306,7 @@ void main() {
const agentDot = el('span', {
position: 'absolute', right: '-1px', bottom: '7px',
width: '6px', height: '6px', borderRadius: '50%',
background: 'oklch(78% 0.14 75)',
background: 'oklch(77% 0.13 82)',
boxShadow: '0 0 0 2px ' + P.surface,
display: 'none', pointerEvents: 'none',
});
@@ -9408,11 +9408,11 @@ void main() {
// DESIGN.md panel toggle - quartet of color squares as the mark.
const designBtn = makeIconBtn({
id: PREFIX + '-design-toggle',
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(58% 0.065 82 / 0.55);flex-shrink:0">
svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px oklch(92% 0 0 / 0.13);flex-shrink:0">
<span style="background:oklch(84% 0.19 80.46)"></span>
<span style="background:oklch(70% 0.12 188)"></span>
<span style="background:oklch(84% 0.035 82)"></span>
<span style="background:oklch(34% 0.014 82)"></span>
<span style="background:oklch(91% 0 0)"></span>
<span style="background:oklch(34% 0 0)"></span>
</span>`,
label: 'DESIGN.md',
ariaLabel: 'Toggle DESIGN.md panel',
@@ -9996,8 +9996,8 @@ void main() {
meta: 'oklch(55% 0 0)',
hairline: 'oklch(88% 0 0)',
hairlineSoft: 'oklch(92% 0 0)',
amber: 'oklch(70% 0.13 65)', // stale-hint accent
amberBg: 'oklch(95% 0.05 80)',
amber: 'oklch(77% 0.13 82)', // stale-hint accent
amberBg: 'oklch(89% 0.055 84)',
};
function designPanelCss(BP) {
@@ -10088,7 +10088,7 @@ void main() {
}
.empty strong { color: ${DP.ink}; display: block; margin-bottom: 6px; font-size: 14px; }
.empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; }
.error { color: oklch(45% 0.15 25); }
.error { color: oklch(58% 0.15 35); }
/* Stale hint */
.stale {
@@ -10240,8 +10240,8 @@ void main() {
content: ''; position: absolute; left: 4px; top: 13px;
width: 8px; height: 8px; border-radius: 50%;
}
.coll .do::before { background: oklch(62% 0.16 145); }
.coll .dont::before { background: oklch(58% 0.22 25); }
.coll .do::before { background: oklch(45% 0.18 145); }
.coll .dont::before { background: oklch(58% 0.15 35); }
.coll .overview-body {
font-size: 12px; line-height: 1.55; color: ${DP.ink2};