diff --git a/cli/bin/commands/ignores.mjs b/cli/bin/commands/ignores.mjs index dc1d6a376..5e0585dfc 100644 --- a/cli/bin/commands/ignores.mjs +++ b/cli/bin/commands/ignores.mjs @@ -7,6 +7,7 @@ import { readDetectionConfig, readRawDetectionConfig, writeDetectionConfig, + extractFindingIgnoreValue, } from '../../lib/impeccable-config.mjs'; const ACTION_ALIASES = new Map([ @@ -235,6 +236,9 @@ function addFile(cwd, args) { function addValue(cwd, args) { const { local, rest } = parseScope(args); const parsed = parseValueArgs(rest); + if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) { + throw new Error(`${parsed.rule} has no extractable ignore value. Use impeccable ignores add-value ${parsed.rule} "*" --file to suppress it in matching files.`); + } const config = readScopeConfig(cwd, local); const key = ignoreValueKey(parsed); const existing = config.ignoreValues.find((entry) => ignoreValueKey(entry) === key); diff --git a/skill/scripts/hook-admin.mjs b/skill/scripts/hook-admin.mjs index 0d8cbaf94..28677c483 100644 --- a/skill/scripts/hook-admin.mjs +++ b/skill/scripts/hook-admin.mjs @@ -35,6 +35,7 @@ import { ensureHookGitExcludes, normalizeIgnoreValue, normalizeIgnoreValueEntries, + extractFindingIgnoreValue, } from './hook-lib.mjs'; const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']); @@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) { throw new Error(`Wildcard value ignores must be scoped with --file , e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`); } + if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) { + throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file to suppress it in matching files.`); + } + const local = parsed.local; const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local })); // Key on the file scope too: the same rule/value legitimately appears more than diff --git a/tests/cli-ignores.test.js b/tests/cli-ignores.test.js index 95f676ed4..8049106fb 100644 --- a/tests/cli-ignores.test.js +++ b/tests/cli-ignores.test.js @@ -115,6 +115,13 @@ describe('impeccable ignores CLI', () => { expect(existsSync(join(root, '.impeccable', 'config.json'))).toBe(false); }); + test('rejects exact values for rules that cannot extract one', () => { + const result = run(['add-value', 'side-tab', 'Inter']); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/side-tab has no extractable ignore value.*add-value side-tab "\*" --file /); + expect(existsSync(join(root, '.impeccable', 'config.json'))).toBe(false); + }); + test('removes an existing broad wildcard value ignore', () => { mkdirSync(join(root, '.impeccable'), { recursive: true }); writeFileSync(join(root, '.impeccable', 'config.json'), JSON.stringify({ diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 15f549e43..e11d3fb62 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -819,6 +819,28 @@ describe('hook-admin.mjs', () => { assert.equal(fs.existsSync(getConfigPath(cwd)), false, 'a refused ignore must not write config'); }); + it('ignore-value refuses exact values for rules that cannot extract one', () => { + assert.throws( + () => runAdmin(['ignore-value', 'cramped-padding', 'padding: 4px 8px']), + /cramped-padding has no extractable ignore value.*ignore-value cramped-padding "\*" --file /, + ); + assert.throws( + () => runAdmin(['ignore-value', 'side-tab', 'Inter', '--file', 'a.css']), + /side-tab has no extractable ignore value.*ignore-value side-tab "\*" --file /, + ); + assert.equal(fs.existsSync(getConfigPath(cwd)), false, 'a refused ignore must not write config'); + + const out = runAdmin(['ignore-value', 'overused-font', 'Inter']); + assert.match(out, /Added overused-font=inter/); + + runAdmin(['ignore-value', 'cramped-padding', '*', '--file', 'index.html']); + const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; + assert.equal(shared.ignoreValues.filter((e) => e.rule === 'cramped-padding').length, 1); + const entry = shared.ignoreValues.find((e) => e.rule === 'cramped-padding'); + assert.equal(entry.value, '*'); + assert.deepEqual(entry.files, ['index.html']); + }); + it('ignore-value --file requires a glob', () => { assert.throws( () => runAdmin(['ignore-value', 'side-tab', '*', '--file']),