baodan/.agents/skills/impeccable/scripts/hook-admin.mjs
wsb1224 e3479f0546 上线阻断问题全部修复
#	问题	修复	文件
1	前端构建失败(引号错误)	size="small type=" → size="small" type="	PosterHistoryPage.vue
2	migrate_014 ORM vs 缺失列	全部改为原始 SQL,不再引用 ORM 模型	migrate_014.py
3	cleanup 字段名错误	output_path → ppt_path	cleanup.py
4	文案生成 case 越权	添加 case.user_id != user_id 校验	poster/service.py
5	存储路径未接通持久化卷	全部改用 get_storage_root()(默认 /app/api/storage/insurance)	config.py, ppt/routes.py, poster/service.py, poster/tasks.py
高风险问题修复
#	问题	修复	文件
6	migrate_019 rollback 撤销成功字段	每个 ALTER 后立即 commit,失败只回滚当前语句	migrate_019.py
7	迁移锁 Windows 不兼容 + 句柄未持久化	全局变量保存锁句柄,支持 Windows msvcrt	api/insurance/db/__init__.py
8	PDF 校验异常时放行	异常返回 False(文件损坏)	security.py
9	健康检查始终返回成功	缺少关键资源时返回 503 + missing 列表	poster/routes.py
10	短密钥掩码泄露原值	≤4 字符返回 ****	ppt_admin_service.py
11	设置无键名白名单	添加 _ALLOWED_SETTING_KEYS 白名单	ppt_admin_service.py
12	容器重启任务永久 stuck	添加 recover_stale_tasks() 启动恢复函数	poster/tasks.py, ppt/parse_worker.py
2026-07-27 13:52:09 +08:00

742 lines
28 KiB
JavaScript

#!/usr/bin/env node
/**
* The Impeccable hooks command manages 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
* node hook-admin.mjs on # set enabled: true
* node hook-admin.mjs off # set enabled: false
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
* node hook-admin.mjs ignore-rule overused-font --all-values
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
* node hook-admin.mjs ignore-value <rule> <value> --local
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
* node hook-admin.mjs ignore-value <rule> "*" # refused: scope it or use ignore-rule
* node hook-admin.mjs reset # remove all config + cache
*
* Designed to be invoked by the LLM from the reference/hooks.md flow.
* Output is human-readable; the harness will pass it back to the user.
*/
import fs from 'node:fs';
import path from 'node:path';
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
import {
getConfigPath,
getLocalConfigPath,
getCachePath,
getPendingPath,
readConfig,
DEFAULT_CONFIG,
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
// The Stop deep pass scans every UI file touched in the session with the full
// rule set, so it gets a longer budget than the per-edit pass. Only Claude
// Code and Codex dispatch a native Stop hook event, so only those manifests
// carry the entry. Keep these shapes in sync with
// scripts/lib/transformers/hooks.js in the repo.
const STOP_TIMEOUT_SECONDS = 30;
const STOP_STATUS_MESSAGE = 'Design deep pass';
function stopManifestEntry(command) {
return {
hooks: [
{
type: 'command',
command,
timeout: STOP_TIMEOUT_SECONDS,
statusMessage: STOP_STATUS_MESSAGE,
},
],
};
}
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node ".agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
{
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
// manifest is honored by the CLI (once committed to the default branch) and
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
provider: '.github',
skillRel: '.github/skills/impeccable',
destRel: '.github/hooks/impeccable.json',
manifest: () => ({
version: 1,
hooks: {
postToolUse: [
{
type: 'command',
matcher: 'edit|create|apply_patch',
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
timeoutSec: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
try {
return { exists: true, malformed: false, raw: JSON.parse(fs.readFileSync(filePath, 'utf-8')) };
} catch {
return { exists: true, malformed: true, raw: 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;
}
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 = stripDetectorKeys(hookSection(existing));
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
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,
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,
},
};
}
function mergeDetectorConfig(existing, seed = null) {
const base = existing && typeof existing === 'object' ? existing : {};
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([...out.ignoreRules, ...base.ignoreRules.map(String)]));
}
if (Array.isArray(base.ignoreFiles)) {
out.ignoreFiles = Array.from(new Set([...out.ignoreFiles, ...base.ignoreFiles.map(String)]));
}
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) {
// Sorted: a file scope is a set. Comparing stored order made an on-disk scope
// miss the sorted argv form, so a re-add duplicated the entry and a remove
// silently failed. Every key that hashes `files` must sort — there are four.
const files = Array.isArray(entry.files) && entry.files.length > 0 ? [...entry.files].sort().join('\x1f') : '';
return `${entry.rule}\0${entry.value}\0${files}`;
}
function statusReport(cwd) {
const shared = readRawConfigFile(getConfigPath(cwd));
const local = readRawConfigFile(getLocalConfigPath(cwd));
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
if (info.exists) return relPath;
return `${relPath} (${absent})`;
};
// Show the file scope. Dropping it rendered a file-scoped entry as
// `design-system-font-size=*`, which reads as the project-wide wildcard this
// command refuses — the opposite of what is on disk. Matches the
// `rule=value [files]` shape `impeccable ignores list` already prints.
const ignoreValues = cfg.ignoreValues.map((entry) => {
const scope = Array.isArray(entry.files) && entry.files.length ? ` [${entry.files.join(', ')}]` : '';
return `${entry.rule}=${entry.value}${scope}`;
});
const lines = [
`Impeccable design hook`,
` state: ${cfg.enabled ? 'enabled' : 'disabled'}`,
` shared file: ${fileState(shared, cfgPath, 'using defaults; file not present')}`,
` local file: ${fileState(local, localPath, 'not present')}`,
` ignoreRules: ${cfg.ignoreRules.length ? cfg.ignoreRules.join(', ') : '(none)'}`,
` ignoreFiles: ${cfg.ignoreFiles.length ? cfg.ignoreFiles.join(', ') : '(none)'}`,
` ignoreValues: ${ignoreValues.length ? ignoreValues.join(', ') : '(none)'}`,
` maxFindings: ${cfg.limits.maxFindings}`,
` maxChars: ${cfg.limits.maxChars}`,
` env override: ${envState}`,
` cache file: ${fs.existsSync(getCachePath(cwd)) ? cachePath : `${cachePath} (not present)`}`,
];
return lines.join('\n');
}
function setEnabled(cwd, value) {
const config = mergeHookConfig(readRawHookConfig(cwd));
config.enabled = value;
const target = writeHookConfig(cwd, config);
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
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}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
// flat entry shape, where the marker lives under the shell-command keys.
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
return String(rule || '').trim().toLowerCase();
}
function parseIgnoreRuleArgs(args) {
const positionals = [];
let allValues = false;
for (let i = 0; i < args.length; i++) {
const arg = String(args[i] || '');
if (arg === '--all-values') {
allValues = true;
} else if (arg === '--reason') {
while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) i++;
} else if (arg.startsWith('--reason=')) {
// Accepted for command symmetry; ignoreRules stores rule ids only.
} else if (arg.startsWith('--')) {
throw new Error(`Unknown ignore-rule flag: ${arg}`);
} else {
positionals.push(arg);
}
}
return {
rule: normalizeRuleId(positionals[0]),
allValues,
};
}
function addIgnoreRule(cwd, args) {
const parsed = parseIgnoreRuleArgs(args);
const rule = parsed.rule;
if (!rule) throw new Error(`Pass a rule id, e.g. ${IMPECCABLE_COMMAND} hooks ignore-rule side-tab`);
if (rule === 'overused-font' && !parsed.allValues) {
throw new Error(`overused-font is value-specific by default. Use ${IMPECCABLE_COMMAND} hooks ignore-value overused-font <font> for a confirmed font, or ${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.`);
}
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
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_COMMAND} hooks ignore-file "src/legacy/**"`);
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
writeDetectorConfig(cwd, config);
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
}
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
// success and wrote an entry with no files: the user asked to scope a rule to one
// file and silently got the project-wide suppression instead. Refuse it.
function requireGlob(raw, flag) {
const glob = String(raw ?? '').trim();
if (!glob) throw new Error(`${flag} requires a non-empty glob`);
// A following flag is not a glob. `--file --reason "why"` consumed `--reason`
// as the scope and left the reason text to fold into the value, storing
// value="* why" files=["--reason"] and reporting success. Same silent-no-op
// class as an unknown flag folding into the value; refuse it the same way.
if (glob.startsWith('--')) throw new Error(`${flag} requires a glob, got the flag ${glob}`);
return glob;
}
function parseIgnoreValueArgs(args) {
const positionals = [];
const files = [];
let shared = false;
let local = false;
let reason = '';
for (let i = 0; i < args.length; i++) {
const arg = String(args[i] || '');
if (arg === '--shared') {
shared = true;
} else if (arg === '--local') {
local = true;
} else if (arg === '--reason') {
const chunks = [];
while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) {
chunks.push(args[++i]);
}
reason = chunks.join(' ').trim();
} else if (arg.startsWith('--reason=')) {
reason = arg.slice('--reason='.length).trim();
} else if (arg === '--file' || arg === '--files') {
if (i + 1 >= args.length) throw new Error(`${arg} requires a glob`);
files.push(requireGlob(args[++i], arg));
} else if (arg.startsWith('--file=')) {
files.push(requireGlob(arg.slice('--file='.length), '--file'));
} else if (arg.startsWith('--files=')) {
files.push(requireGlob(arg.slice('--files='.length), '--files'));
} else if (arg.startsWith('--')) {
// Otherwise a typo folds into the value: `ignore-value overused-font Inter
// --shard` stored the value "inter --shard", which matches no finding, and
// reported success. Matches `impeccable ignores add-value`.
throw new Error(`Unknown ignore-value flag: ${arg}`);
} else {
positionals.push(arg);
}
}
const [rule, ...valueParts] = positionals;
return {
rule: String(rule || '').trim().toLowerCase(),
value: normalizeIgnoreValue(valueParts.join(' ')),
// Sorted: the dedup key compares the files array, so an unsorted scope made
// `--file b.css --file a.css` a different entry from `--file a.css --file b.css`.
files: Array.from(new Set(files.filter(Boolean))).sort(),
shared,
local,
reason,
};
}
function addIgnoreValue(cwd, args) {
const parsed = parseIgnoreValueArgs(args);
if (!parsed.rule || !parsed.value) {
throw new Error(`Pass a rule id and value, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value overused-font Inter`);
}
if (parsed.shared && parsed.local) {
throw new Error('Pass only one scope flag: --shared or --local');
}
// A bare `*` would suppress the rule everywhere, which is ignore-rule's job and
// not what a finding in one file justifies. detector.ignoreValues honours a
// `files` scope, so require one — matching `impeccable ignores add-value`.
if (parsed.value === '*' && parsed.files.length === 0) {
// `ignore-rule overused-font` refuses on its own without --all-values, so
// naming the bare form here would hand the user a second error.
const projectWide = parsed.rule === 'overused-font'
? `${IMPECCABLE_COMMAND} hooks ignore-rule ${parsed.rule} --all-values`
: `${IMPECCABLE_COMMAND} hooks ignore-rule ${parsed.rule}`;
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
// once with different scopes, and a rule+value-only key overwrote them.
const key = ignoreValueEntryKey({ rule: parsed.rule, value: parsed.value, files: parsed.files });
const existing = config.ignoreValues.find((entry) => ignoreValueEntryKey(entry) === key);
if (existing) {
if (parsed.reason) existing.reason = parsed.reason;
} else {
const entry = {
rule: parsed.rule,
value: parsed.value,
};
if (parsed.files.length) entry.files = parsed.files;
entry.createdAt = new Date().toISOString();
if (parsed.reason) entry.reason = parsed.reason;
config.ignoreValues.push(entry);
}
const target = writeDetectorConfig(cwd, config, { local });
const scope = local ? 'local detector.ignoreValues' : 'shared detector.ignoreValues';
const scopeSuffix = parsed.files.length ? ` scoped to ${parsed.files.join(', ')}` : '';
return `Added ${parsed.rule}=${parsed.value}${scopeSuffix} 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/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) && !('detector' in raw))) continue;
const { hook, detector, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
removed.push(path.relative(cwd, filePath) || filePath);
}
} catch { /* ignore */ }
}
return removed.length
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
: 'No hook config or cache to remove. Already at defaults.';
}
function main() {
const [, , actionArg, ...rest] = process.argv;
const action = (actionArg || 'status').toLowerCase();
const cwd = process.cwd();
if (!ACTIONS.has(action)) {
process.stderr.write(`Unknown action: ${action}\nValid: ${Array.from(ACTIONS).join(', ')}\n`);
process.exit(1);
}
try {
let out = '';
switch (action) {
case 'status': out = statusReport(cwd); break;
case 'on': out = setEnabled(cwd, true); break;
case 'off': out = setEnabled(cwd, false); break;
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
case 'reset': out = reset(cwd); break;
}
process.stdout.write(out + '\n');
} catch (err) {
process.stderr.write(`Error: ${err.message || err}\n`);
process.exit(1);
}
}
main();