Let hooks ignore-value scope a rule to files, and stop churning the config

Fallout from suppressing the overlay's font-size findings: the narrowest
exception detector.ignoreValues supports was unreachable from the path the hook
tells the model to use, so the guidance steered to the blunt instrument instead.

- hook-admin's ignore-value now takes --file / --files / --file= / --files=,
  matching `impeccable ignores add-value`, which already had them. Without it the
  only file-scoped option was ignore-file, which silences every rule for a path
  permanently, including rules not yet written.
- A bare wildcard value is now refused with a message pointing at either --file
  or ignore-rule. Previously `ignore-value <rule> "*"` quietly wrote a
  project-wide suppression from a single file's finding.
- ignore-value keyed entries on rule+value only, so a second scope for the same
  rule overwrote the first instead of coexisting. Key on the file scope too.
- An unknown flag folded into the value: `ignore-value overused-font Inter
  --shard` stored "inter --shard", matched nothing, and reported success. Reject
  it, as the sibling command does.

Config churn: normalizeIgnoreValueEntries runs on every write and emitted keys as
rule, value, files, reason, createdAt while the config on disk uses createdAt
before reason. Any edit therefore rewrote every untouched entry (35 churned lines
for a one-line change). Pin the canonical order in both copies of the normalizer
and in ignores.mjs, and add a test that the two copies cannot drift apart.

Also point the hook's own footer and reference/hooks.md at the file-scoped form
first, and say plainly what ignore-file costs.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-17 15:06:08 -07:00
co-authored by Claude
parent 331c2f2696
commit f148496f67
6 changed files with 197 additions and 23 deletions
+34 -7
View File
@@ -13,6 +13,8 @@
* 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.
@@ -534,12 +536,13 @@ function addIgnoreFile(cwd, 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 = args[i];
const arg = String(args[i] || '');
if (arg === '--shared') {
shared = true;
} else if (arg === '--local') {
@@ -550,8 +553,20 @@ function parseIgnoreValueArgs(args) {
chunks.push(args[++i]);
}
reason = chunks.join(' ').trim();
} else if (String(arg).startsWith('--reason=')) {
reason = String(arg).slice('--reason='.length).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(String(args[++i]).trim());
} else if (arg.startsWith('--file=')) {
files.push(arg.slice('--file='.length).trim());
} else if (arg.startsWith('--files=')) {
files.push(arg.slice('--files='.length).trim());
} 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);
}
@@ -561,6 +576,7 @@ function parseIgnoreValueArgs(args) {
return {
rule: String(rule || '').trim().toLowerCase(),
value: normalizeIgnoreValue(valueParts.join(' ')),
files: Array.from(new Set(files.filter(Boolean))),
shared,
local,
reason,
@@ -577,10 +593,19 @@ function addIgnoreValue(cwd, args) {
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) {
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 ${IMPECCABLE_COMMAND} hooks ignore-rule ${parsed.rule}.`);
}
const local = parsed.local;
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);
// 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;
@@ -588,15 +613,17 @@ function addIgnoreValue(cwd, args) {
const entry = {
rule: parsed.rule,
value: parsed.value,
createdAt: new Date().toISOString(),
};
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';
return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`;
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) {