From b32a02d02fb75886e1766467d03235e106fe1693 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 3 Aug 2026 09:17:23 -0700 Subject: [PATCH 1/6] Fix ignore-file flag handling AI assistance was used to reproduce the issue, implement the fix, and add regression coverage. --- skill/scripts/hook-admin.mjs | 44 +++++++++++++++++++++++++++++++----- tests/hook.test.mjs | 22 ++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/skill/scripts/hook-admin.mjs b/skill/scripts/hook-admin.mjs index fd3ef637d..42efaacca 100644 --- a/skill/scripts/hook-admin.mjs +++ b/skill/scripts/hook-admin.mjs @@ -10,7 +10,7 @@ * node hook-admin.mjs off # set enabled: false * node hook-admin.mjs ignore-rule # append to ignoreRules * node hook-admin.mjs ignore-rule overused-font --all-values - * node hook-admin.mjs ignore-file # append to ignoreFiles + * node hook-admin.mjs ignore-file [--local] # append to ignoreFiles * node hook-admin.mjs ignore-value # append to shared ignoreValues * node hook-admin.mjs ignore-value --local * node hook-admin.mjs ignore-value "*" --file # rule off in only @@ -558,12 +558,44 @@ function addIgnoreRule(cwd, args) { return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`; } -function addIgnoreFile(cwd, glob) { +function parseIgnoreFileArgs(args) { + const positionals = []; + let shared = false; + let local = false; + + for (const raw of args) { + const arg = String(raw || ''); + if (arg === '--shared') { + shared = true; + } else if (arg === '--local') { + local = true; + } else if (arg === '--reason' || arg.startsWith('--reason=')) { + throw new Error('--reason is not supported for ignore-file because detector.ignoreFiles stores globs only; use ignore-value when a documented rule-specific exception fits'); + } else if (arg.startsWith('--')) { + throw new Error(`Unknown ignore-file flag: ${arg}`); + } else { + positionals.push(arg); + } + } + + if (shared && local) throw new Error('Pass only one scope flag: --shared or --local'); + if (positionals.length > 1) throw new Error('Pass exactly one glob to ignore-file'); + + return { + glob: positionals[0], + local, + }; +} + +function addIgnoreFile(cwd, args) { + const parsed = parseIgnoreFileArgs(args); + const glob = parsed.glob; if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`); - const config = mergeDetectorConfig(readRawDetectorConfig(cwd)); + const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local: parsed.local })); if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob); - writeDetectorConfig(cwd, config); - return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`; + const target = writeDetectorConfig(cwd, config, { local: parsed.local }); + const scope = parsed.local ? 'local detector.ignoreFiles' : 'shared detector.ignoreFiles'; + return `Added "${glob}" to ${scope} (${path.relative(cwd, target) || target}). Current: ${config.ignoreFiles.join(', ')}`; } // An empty glob used to be dropped by filter(Boolean), so `--file=` reported @@ -727,7 +759,7 @@ function main() { 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-file': out = addIgnoreFile(cwd, rest); break; case 'ignore-value': out = addIgnoreValue(cwd, rest); break; case 'reset': out = reset(cwd); break; } diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 895b4f0f0..89b40ac01 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -985,6 +985,28 @@ describe('hook-admin.mjs', () => { ); }); + it('ignore-file --local writes only the private detector config', () => { + const out = runAdmin(['ignore-file', '/abs/path/personal.html', '--local']); + + assert.equal(fs.existsSync(getConfigPath(cwd)), false); + const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).detector; + assert.deepEqual(local.ignoreFiles, ['/abs/path/personal.html']); + assert.match(out, /local detector\.ignoreFiles/); + }); + + it('ignore-file refuses unsupported reasons and unknown flags', () => { + assert.throws( + () => runAdmin(['ignore-file', 'src/legacy/**', '--reason', 'machine-local path']), + /--reason is not supported for ignore-file/, + ); + assert.throws( + () => runAdmin(['ignore-file', 'src/legacy/**', '--shard']), + /Unknown ignore-file flag: --shard/, + ); + assert.equal(fs.existsSync(getConfigPath(cwd)), false); + assert.equal(fs.existsSync(getLocalConfigPath(cwd)), false); + }); + it('ignore-file writes shared config that suppresses a later hook run', async () => { const file = path.join(cwd, 'src/ConfirmedCard.html'); fs.mkdirSync(path.dirname(file), { recursive: true }); From dd0279b6bd68cabc2cf74833be9ef169e7eb10cb Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 3 Aug 2026 09:21:13 -0700 Subject: [PATCH 2/6] Document ignore-file scope flags AI assistance was used to address automated review feedback and validate the documentation correction. --- skill/scripts/hook-admin.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skill/scripts/hook-admin.mjs b/skill/scripts/hook-admin.mjs index 42efaacca..43ad0b878 100644 --- a/skill/scripts/hook-admin.mjs +++ b/skill/scripts/hook-admin.mjs @@ -10,7 +10,7 @@ * node hook-admin.mjs off # set enabled: false * node hook-admin.mjs ignore-rule # append to ignoreRules * node hook-admin.mjs ignore-rule overused-font --all-values - * node hook-admin.mjs ignore-file [--local] # append to ignoreFiles + * node hook-admin.mjs ignore-file [--shared|--local] # append to ignoreFiles * node hook-admin.mjs ignore-value # append to shared ignoreValues * node hook-admin.mjs ignore-value --local * node hook-admin.mjs ignore-value "*" --file # rule off in only From 3125864d1a98edcdbb6abb501d3c27e2af93c1cb Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 3 Aug 2026 09:38:02 -0700 Subject: [PATCH 3/6] Preserve advisory detector settings AI assistance was used to reproduce and fix automated review feedback, add regression coverage, and run validation. --- skill/scripts/hook-admin.mjs | 8 +++++++- tests/hook.test.mjs | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/skill/scripts/hook-admin.mjs b/skill/scripts/hook-admin.mjs index 43ad0b878..8e1230d9f 100644 --- a/skill/scripts/hook-admin.mjs +++ b/skill/scripts/hook-admin.mjs @@ -166,7 +166,7 @@ function readRawConfigFile(filePath) { } } -const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']); +const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']); function hookSection(unified) { return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook) @@ -259,12 +259,18 @@ function mergeDetectorConfig(existing, seed = null) { if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) { out.designSystem = { ...seed.designSystem }; } + if (seed?.advisoryRules === 'include' || seed?.advisoryRules === 'exclude') { + out.advisoryRules = seed.advisoryRules; + } if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) { out.designSystem = { ...(out.designSystem || {}), enabled: base.designSystem.enabled === false ? false : true, }; } + if (base.advisoryRules === 'include' || base.advisoryRules === 'exclude') { + out.advisoryRules = base.advisoryRules; + } if (Array.isArray(base.ignoreRules)) { out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)])); } diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 89b40ac01..f7b39d2d7 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -994,6 +994,19 @@ describe('hook-admin.mjs', () => { assert.match(out, /local detector\.ignoreFiles/); }); + it('ignore-file --local preserves the local advisory-rule preference', () => { + fs.mkdirSync(path.dirname(getLocalConfigPath(cwd)), { recursive: true }); + fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ + detector: { advisoryRules: 'include' }, + })); + + runAdmin(['ignore-file', '/abs/path/personal.html', '--local']); + + const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).detector; + assert.equal(local.advisoryRules, 'include'); + assert.deepEqual(local.ignoreFiles, ['/abs/path/personal.html']); + }); + it('ignore-file refuses unsupported reasons and unknown flags', () => { assert.throws( () => runAdmin(['ignore-file', 'src/legacy/**', '--reason', 'machine-local path']), From ae118ebf57e4f2422bff058bc71a612f037dc524 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 3 Aug 2026 10:05:05 -0700 Subject: [PATCH 4/6] Migrate legacy advisory settings AI assistance: Codex identified, implemented, and validated this review follow-up under maintainer authorization. --- skill/scripts/hook-admin.mjs | 16 +++++++++++++++- tests/hook.test.mjs | 29 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/skill/scripts/hook-admin.mjs b/skill/scripts/hook-admin.mjs index 8e1230d9f..ac3363d27 100644 --- a/skill/scripts/hook-admin.mjs +++ b/skill/scripts/hook-admin.mjs @@ -200,6 +200,15 @@ function stripDetectorKeys(raw) { return out; } +function pickDetectorKeys(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 = {}) { @@ -207,10 +216,15 @@ function writeHookConfig(cwd, hookConfig, opts = {}) { 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)); + const existingHookSection = hookSection(existing); + const existingHook = stripDetectorKeys(existingHookSection); + const legacyDetector = pickDetectorKeys(existingHookSection); // 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 } }; + if (Object.keys(legacyDetector).length > 0) { + next.detector = mergeDetectorConfig(detectorSection(existing), mergeDetectorConfig(legacyDetector)); + } fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n'); return filePath; diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index f7b39d2d7..87c55183a 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -1007,6 +1007,35 @@ describe('hook-admin.mjs', () => { assert.deepEqual(local.ignoreFiles, ['/abs/path/personal.html']); }); + for (const command of ['on', 'off']) { + it(`hooks ${command} migrates a legacy hook advisory-rule preference`, () => { + fs.mkdirSync(path.dirname(getConfigPath(cwd)), { recursive: true }); + fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ + hook: { advisoryRules: 'include' }, + })); + + runAdmin([command]); + + const config = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')); + assert.equal(config.hook.advisoryRules, undefined); + assert.equal(config.detector.advisoryRules, 'include'); + }); + } + + it('hooks on keeps the canonical advisory-rule preference during legacy migration', () => { + fs.mkdirSync(path.dirname(getConfigPath(cwd)), { recursive: true }); + fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ + hook: { advisoryRules: 'include' }, + detector: { advisoryRules: 'exclude' }, + })); + + runAdmin(['on']); + + const config = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')); + assert.equal(config.hook.advisoryRules, undefined); + assert.equal(config.detector.advisoryRules, 'exclude'); + }); + it('ignore-file refuses unsupported reasons and unknown flags', () => { assert.throws( () => runAdmin(['ignore-file', 'src/legacy/**', '--reason', 'machine-local path']), From 57ce11288f55ad73a9bf3af976e62bc65cb59893 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 3 Aug 2026 10:23:13 -0700 Subject: [PATCH 5/6] Preserve detector extensions AI assistance: Codex addressed review feedback and validated this follow-up under maintainer authorization. --- skill/scripts/hook-admin.mjs | 6 +++++- tests/hook.test.mjs | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/skill/scripts/hook-admin.mjs b/skill/scripts/hook-admin.mjs index ac3363d27..5a0c29abd 100644 --- a/skill/scripts/hook-admin.mjs +++ b/skill/scripts/hook-admin.mjs @@ -223,7 +223,11 @@ function writeHookConfig(cwd, hookConfig, opts = {}) { // (consent, quiet, auditLog) survive an Impeccable hooks edit. const next = { ...existing, hook: { ...existingHook, ...hookConfig } }; if (Object.keys(legacyDetector).length > 0) { - next.detector = mergeDetectorConfig(detectorSection(existing), mergeDetectorConfig(legacyDetector)); + const existingDetector = detectorSection(existing) || {}; + next.detector = { + ...existingDetector, + ...mergeDetectorConfig(existingDetector, mergeDetectorConfig(legacyDetector)), + }; } fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n'); diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 87c55183a..18012f5c7 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -1026,7 +1026,10 @@ describe('hook-admin.mjs', () => { fs.mkdirSync(path.dirname(getConfigPath(cwd)), { recursive: true }); fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { advisoryRules: 'include' }, - detector: { advisoryRules: 'exclude' }, + detector: { + advisoryRules: 'exclude', + extensions: [{ ext: '.blade.php', engine: 'html' }], + }, })); runAdmin(['on']); @@ -1034,6 +1037,7 @@ describe('hook-admin.mjs', () => { const config = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')); assert.equal(config.hook.advisoryRules, undefined); assert.equal(config.detector.advisoryRules, 'exclude'); + assert.deepEqual(config.detector.extensions, [{ ext: '.blade.php', engine: 'html' }]); }); it('ignore-file refuses unsupported reasons and unknown flags', () => { From 2345868c7bc68b6d0cf856e7259b0dc03484160c Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 3 Aug 2026 10:42:07 -0700 Subject: [PATCH 6/6] Preserve detector extension mappings Keep unmanaged detector fields when ignore-file updates the canonical detector configuration. Add a regression covering existing extension mappings.\n\nAI assistance: Codex implemented and validated this change under maintainer authorization. --- skill/scripts/hook-admin.mjs | 8 ++++++-- tests/hook.test.mjs | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/skill/scripts/hook-admin.mjs b/skill/scripts/hook-admin.mjs index 5a0c29abd..e8d9e2ada 100644 --- a/skill/scripts/hook-admin.mjs +++ b/skill/scripts/hook-admin.mjs @@ -240,10 +240,14 @@ function writeDetectorConfig(cwd, detectorConfig, opts = {}) { 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 existingDetectorSection = detectorSection(existing) || {}; + const existingDetector = mergeDetectorConfig(existingDetectorSection); const next = { ...existing, - detector: mergeDetectorConfig(detectorConfig, existingDetector), + detector: { + ...existingDetectorSection, + ...mergeDetectorConfig(detectorConfig, existingDetector), + }, }; if (Object.keys(nextHook).length > 0) next.hook = nextHook; else delete next.hook; diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 18012f5c7..631fc245d 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -1058,9 +1058,15 @@ describe('hook-admin.mjs', () => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, '
Card
'); + fs.mkdirSync(path.dirname(getConfigPath(cwd)), { recursive: true }); + fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ + detector: { extensions: [{ ext: '.blade.php', engine: 'html' }] }, + })); + runAdmin(['ignore-file', 'src/ConfirmedCard.html']); const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector; + assert.deepEqual(shared.extensions, [{ ext: '.blade.php', engine: 'html' }]); assert.deepEqual(shared.ignoreFiles, ['src/ConfirmedCard.html']); const r = await runHook({