mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
* Scope a single rule to a file with ignore-value "*" --file
`ignore-file <glob>` was the only file-scoped escape the hook offered, and
it is far blunter than most findings justify: it silences every rule for
that path forever, including rules not written yet. A real UI surface with
one noisy rule had no proportionate option.
Add a file scope to `ignore-value`, so one rule can be turned off in
matching files while staying active everywhere else:
hooks ignore-value design-system-font-size "*" --file "src/widget.js"
- Refuse a bare `"*"` with no `--file`. Suppressing a rule project-wide is
`ignore-rule`'s job, and the error says so.
- Reject unknown `--flags` instead of folding them into the value.
`ignore-value overused-font Inter --shard` stored the value
"inter --shard", matched no finding, and reported success.
- Key dedup on the file scope too. The same rule/value legitimately
appears more than once with different scopes; the old rule+value key
silently overwrote the earlier entry.
- Keep normalizer key order (rule, value, files, createdAt, reason) in
step across both copies. Normalizing runs on every write, so emitting a
different order than what is on disk rewrites untouched entries.
- Lead with the narrow form in the hook's directive footer and hooks.md;
`ignore-file` is now documented as the whole-file-out-of-scope case.
Dogfoods it on skill/scripts/live-browser.js, where all 32 findings are
design-system-font-size: the overlay is injected over arbitrary host pages
and builds a self-contained UI, so DESIGN.md's ramp does not describe it.
The other rules stay live for that file.
Assisted-by: Claude Code
* Show the file scope in hooks status, and stop the wildcard error misdirecting
Two findings from Cursor.
status formatted every ignore value as rule=value and dropped files. Now
that the primary hooks path writes file-scoped `"*"` entries, that rendered
`design-system-font-size=*` — which reads as exactly the project-wide
wildcard this command refuses, the opposite of what is on disk. Print the
scope, matching the `rule=value [files]` shape `impeccable ignores list`
already uses. This repo's own config already carries several scoped
wildcards written through the CLI path, so status has been under-reporting
them.
The bare-wildcard refusal always pointed at `ignore-rule <rule>`. For
overused-font that command refuses on its own without --all-values, so the
guidance handed the user a second error. Name the flag for that rule.
Assisted-by: Claude Code
* Refuse an empty --file glob, and store multi-file scopes in canonical order
Two Copilot findings, both the silent-no-op class this PR exists to remove.
An empty glob was dropped by filter(Boolean). So
`ignore-value overused-font Inter --file=` reported "Added
overused-font=inter" 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 — broader than what they asked for, reported as success. Refuse an
empty or whitespace glob on every form (--file, --file=, --files, --files=)
in both the hook-admin and CLI paths.
Multi-file scopes were deduped but not ordered, and the dedup key compares
the files array, so `--file b.css --file a.css` stored a second entry
distinct from `--file a.css --file b.css`. Sort at parse so storage is
canonical, and sort inside the key so entries already on disk in another
order still compare equal.
Assisted-by: Claude Code
* Sort files in every dedup key, not just two of the four
My previous commit sorted the file scope at parse time and inside
ignoreValueFilesKey, and stopped there. Cursor pointed out ignoreValueKey
(CLI) and ignoreValueEntryKey (hook-admin) still joined `files` in stored
order, so add/remove dedup missed any on-disk scope whose glob order
differed from the sorted argv form: a re-add duplicated the entry and a
remove silently failed.
Four functions hash `files`; I had fixed two. All four sort now. The
remaining `files.join(', ')` call sites are display, not keys.
Verified against a config seeded in non-sorted order, as an older client
would have written it: the re-add updates the existing entry rather than
duplicating it, and remove-value finds it. Test covers that shape.
Assisted-by: Claude Code
* Refuse a following flag as a --file glob
Cursor again, same class as the last two. requireGlob checked non-empty but
not whether the argv it consumed was itself a flag, so
`ignore-value design-system-font-size "*" --file --reason "why"` took
`--reason` as the scope, left "why" to fold into the value, stored
value="* why" files=["--reason"], and reported success. Garbage, announced
as done.
Refuse a glob starting with `--`, in both the hook-admin and CLI paths.
Assisted-by: Claude Code
352 lines
14 KiB
JavaScript
352 lines
14 KiB
JavaScript
import path from 'node:path';
|
|
|
|
import {
|
|
getConfigPath,
|
|
getLocalConfigPath,
|
|
normalizeIgnoreValue,
|
|
readDetectionConfig,
|
|
readRawDetectionConfig,
|
|
writeDetectionConfig,
|
|
} from '../../lib/impeccable-config.mjs';
|
|
|
|
const ACTION_ALIASES = new Map([
|
|
['status', 'list'],
|
|
['ls', 'list'],
|
|
['list', 'list'],
|
|
['add-rule', 'add-rule'],
|
|
['ignore-rule', 'add-rule'],
|
|
['add-file', 'add-file'],
|
|
['ignore-file', 'add-file'],
|
|
['add-value', 'add-value'],
|
|
['ignore-value', 'add-value'],
|
|
['update-value', 'add-value'],
|
|
['remove-rule', 'remove-rule'],
|
|
['rm-rule', 'remove-rule'],
|
|
['remove-file', 'remove-file'],
|
|
['rm-file', 'remove-file'],
|
|
['remove-value', 'remove-value'],
|
|
['rm-value', 'remove-value'],
|
|
['clear', 'clear'],
|
|
]);
|
|
|
|
function printUsage() {
|
|
console.log(`Usage: impeccable ignores <action> [options]
|
|
|
|
Manage detector ignores in .impeccable config.
|
|
|
|
Actions:
|
|
list Show merged, shared, and local ignores
|
|
add-rule <rule> [--all-values] Ignore a rule
|
|
add-file <glob> Ignore files by glob
|
|
add-value <rule> <value> Ignore one rule/value pair
|
|
remove-rule <rule> Remove a rule ignore
|
|
remove-file <glob> Remove a file ignore
|
|
remove-value <rule> <value> Remove a rule/value ignore
|
|
clear Clear detector ignores in the selected scope
|
|
|
|
Scope:
|
|
--shared Write .impeccable/config.json (default)
|
|
--local Write .impeccable/config.local.json
|
|
--all For remove/clear, apply to shared and local
|
|
|
|
Value options:
|
|
--file <glob> Scope add-value/remove-value to a file glob
|
|
--reason <text> Store or update a reason on add-value
|
|
|
|
Examples:
|
|
impeccable ignores add-file "src/legacy/**"
|
|
impeccable ignores add-value overused-font Inter --reason "Brand font"
|
|
impeccable ignores add-value design-system-color "*" --file "src/demo.css"
|
|
impeccable ignores remove-value overused-font Inter`);
|
|
}
|
|
|
|
function parseScope(args, { allowAll = false } = {}) {
|
|
const rest = [];
|
|
let local = false;
|
|
let shared = false;
|
|
let all = false;
|
|
for (const arg of args) {
|
|
if (arg === '--local') local = true;
|
|
else if (arg === '--shared') shared = true;
|
|
else if (arg === '--all') all = true;
|
|
else rest.push(arg);
|
|
}
|
|
if ([local, shared, all].filter(Boolean).length > 1) {
|
|
throw new Error(`Pass only one scope flag: --shared${allowAll ? ', --local, or --all' : ' or --local'}`);
|
|
}
|
|
if (all && !allowAll) throw new Error('--all is only supported for remove and clear actions');
|
|
return { local, all, rest };
|
|
}
|
|
|
|
// 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 parseValueArgs(args, { allowUnscopedWildcard = false } = {}) {
|
|
const positionals = [];
|
|
const files = [];
|
|
let reason = '';
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = String(args[i] || '');
|
|
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('--')) {
|
|
throw new Error(`Unknown add-value flag: ${arg}`);
|
|
} else {
|
|
positionals.push(arg);
|
|
}
|
|
}
|
|
|
|
const [rule, ...valueParts] = positionals;
|
|
const value = normalizeIgnoreValue(valueParts.join(' '));
|
|
if (!rule || !value) throw new Error('Pass a rule id and value, e.g. impeccable ignores add-value overused-font Inter');
|
|
// 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`.
|
|
const scopedFiles = Array.from(new Set(files.filter(Boolean))).sort();
|
|
if (value === '*' && scopedFiles.length === 0 && !allowUnscopedWildcard) {
|
|
throw new Error('Wildcard value ignores must be scoped with --file <glob>.');
|
|
}
|
|
return {
|
|
rule: String(rule).trim().toLowerCase(),
|
|
value,
|
|
files: scopedFiles,
|
|
reason,
|
|
};
|
|
}
|
|
|
|
function formatValues(values) {
|
|
if (!values.length) return '(none)';
|
|
return values
|
|
.map((entry) => {
|
|
const fileSuffix = Array.isArray(entry.files) && entry.files.length
|
|
? ` [${entry.files.join(', ')}]`
|
|
: '';
|
|
const reasonSuffix = entry.reason ? ` - ${entry.reason}` : '';
|
|
return `${entry.rule}=${entry.value}${fileSuffix}${reasonSuffix}`;
|
|
})
|
|
.join(', ');
|
|
}
|
|
|
|
function formatConfig(label, config) {
|
|
return [
|
|
`${label}:`,
|
|
` ignoreRules: ${config.ignoreRules.length ? config.ignoreRules.join(', ') : '(none)'}`,
|
|
` ignoreFiles: ${config.ignoreFiles.length ? config.ignoreFiles.join(', ') : '(none)'}`,
|
|
` ignoreValues: ${formatValues(config.ignoreValues)}`,
|
|
` designSystem: ${config.designSystem?.enabled === false ? 'disabled' : 'enabled'}`,
|
|
].join('\n');
|
|
}
|
|
|
|
function list(cwd) {
|
|
const merged = readDetectionConfig(cwd);
|
|
const shared = readRawDetectionConfig(cwd);
|
|
const local = readRawDetectionConfig(cwd, { local: true });
|
|
return [
|
|
'Impeccable detector ignores',
|
|
` shared file: ${path.relative(cwd, getConfigPath(cwd)) || getConfigPath(cwd)}`,
|
|
` local file: ${path.relative(cwd, getLocalConfigPath(cwd)) || getLocalConfigPath(cwd)}`,
|
|
'',
|
|
formatConfig('Merged', merged),
|
|
'',
|
|
formatConfig('Shared', shared),
|
|
'',
|
|
formatConfig('Local', local),
|
|
].join('\n');
|
|
}
|
|
|
|
function readScopeConfig(cwd, local) {
|
|
return readRawDetectionConfig(cwd, { local });
|
|
}
|
|
|
|
function writeScopeConfig(cwd, config, local) {
|
|
return writeDetectionConfig(cwd, config, { local });
|
|
}
|
|
|
|
function parseRuleArgs(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 symmetry with add-value; ignoreRules stores ids only.
|
|
} else if (arg.startsWith('--')) {
|
|
throw new Error(`Unknown add-rule flag: ${arg}`);
|
|
} else {
|
|
positionals.push(arg);
|
|
}
|
|
}
|
|
|
|
return {
|
|
rule: String(positionals[0] || '').trim().toLowerCase(),
|
|
allValues,
|
|
};
|
|
}
|
|
|
|
function addRule(cwd, args) {
|
|
const { local, rest } = parseScope(args);
|
|
const { rule, allValues } = parseRuleArgs(rest);
|
|
if (!rule) throw new Error('Pass a rule id, e.g. impeccable ignores add-rule side-tab');
|
|
if (rule === 'overused-font' && !allValues) {
|
|
throw new Error('overused-font is value-specific by default. Use add-value overused-font <font>, or add-rule overused-font --all-values for broad suppression.');
|
|
}
|
|
const config = readScopeConfig(cwd, local);
|
|
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
|
|
const target = writeScopeConfig(cwd, config, local);
|
|
return `Added ${rule} to ${local ? 'local' : 'shared'} detector ignoreRules (${path.relative(cwd, target) || target}).`;
|
|
}
|
|
|
|
function addFile(cwd, args) {
|
|
const { local, rest } = parseScope(args);
|
|
const glob = String(rest[0] || '').trim();
|
|
if (!glob) throw new Error('Pass a glob, e.g. impeccable ignores add-file "src/legacy/**"');
|
|
const config = readScopeConfig(cwd, local);
|
|
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
|
const target = writeScopeConfig(cwd, config, local);
|
|
return `Added ${glob} to ${local ? 'local' : 'shared'} detector ignoreFiles (${path.relative(cwd, target) || target}).`;
|
|
}
|
|
|
|
function addValue(cwd, args) {
|
|
const { local, rest } = parseScope(args);
|
|
const parsed = parseValueArgs(rest);
|
|
const config = readScopeConfig(cwd, local);
|
|
const key = ignoreValueKey(parsed);
|
|
const existing = config.ignoreValues.find((entry) => ignoreValueKey(entry) === key);
|
|
if (existing) {
|
|
if (parsed.reason) existing.reason = parsed.reason;
|
|
if (parsed.files.length) existing.files = parsed.files;
|
|
} else {
|
|
// rule, value, files, createdAt, reason — the same order the normalizers emit,
|
|
// so a fresh entry survives the next write untouched.
|
|
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 = writeScopeConfig(cwd, config, local);
|
|
return `Added ${parsed.rule}=${parsed.value} to ${local ? 'local' : 'shared'} detector ignoreValues (${path.relative(cwd, target) || target}).`;
|
|
}
|
|
|
|
function removeFromScopes(cwd, args, remover) {
|
|
const { local, all, rest } = parseScope(args, { allowAll: true });
|
|
const scopes = all ? [false, true] : [local];
|
|
const removed = [];
|
|
for (const isLocal of scopes) {
|
|
const config = readScopeConfig(cwd, isLocal);
|
|
const count = remover(config, rest);
|
|
if (count > 0) {
|
|
const target = writeScopeConfig(cwd, config, isLocal);
|
|
removed.push(`${count} from ${isLocal ? 'local' : 'shared'} (${path.relative(cwd, target) || target})`);
|
|
}
|
|
}
|
|
return removed.length ? `Removed ${removed.join(', ')}.` : 'No matching detector ignore found.';
|
|
}
|
|
|
|
function removeRule(cwd, args) {
|
|
return removeFromScopes(cwd, args, (config, rest) => {
|
|
const rule = String(rest[0] || '').trim().toLowerCase();
|
|
if (!rule) throw new Error('Pass a rule id, e.g. impeccable ignores remove-rule side-tab');
|
|
const before = config.ignoreRules.length;
|
|
config.ignoreRules = config.ignoreRules.filter((entry) => entry !== rule);
|
|
return before - config.ignoreRules.length;
|
|
});
|
|
}
|
|
|
|
function removeFile(cwd, args) {
|
|
return removeFromScopes(cwd, args, (config, rest) => {
|
|
const glob = String(rest[0] || '').trim();
|
|
if (!glob) throw new Error('Pass a glob, e.g. impeccable ignores remove-file "src/legacy/**"');
|
|
const before = config.ignoreFiles.length;
|
|
config.ignoreFiles = config.ignoreFiles.filter((entry) => entry !== glob);
|
|
return before - config.ignoreFiles.length;
|
|
});
|
|
}
|
|
|
|
function removeValue(cwd, args) {
|
|
return removeFromScopes(cwd, args, (config, rest) => {
|
|
const parsed = parseValueArgs(rest, { allowUnscopedWildcard: true });
|
|
const key = ignoreValueKey(parsed);
|
|
const before = config.ignoreValues.length;
|
|
config.ignoreValues = config.ignoreValues.filter((entry) => ignoreValueKey(entry) !== key);
|
|
return before - config.ignoreValues.length;
|
|
});
|
|
}
|
|
|
|
function clear(cwd, args) {
|
|
const { local, all, rest } = parseScope(args, { allowAll: true });
|
|
if (rest.length > 0) throw new Error('clear does not take positional arguments');
|
|
const scopes = all ? [false, true] : [local];
|
|
for (const isLocal of scopes) {
|
|
const config = readScopeConfig(cwd, isLocal);
|
|
config.ignoreRules = [];
|
|
config.ignoreFiles = [];
|
|
config.ignoreValues = [];
|
|
writeScopeConfig(cwd, config, isLocal);
|
|
}
|
|
return `Cleared detector ignores in ${all ? 'shared and local config' : local ? 'local config' : 'shared config'}.`;
|
|
}
|
|
|
|
function ignoreValueKey(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 ? [...entry.files].sort().join('\x1f') : '';
|
|
return `${String(entry.rule || '').trim().toLowerCase()}\0${normalizeIgnoreValue(entry.value)}\0${files}`;
|
|
}
|
|
|
|
export async function run(args = [], opts = {}) {
|
|
const cwd = opts.cwd || process.cwd();
|
|
const actionArg = args[0] || 'list';
|
|
if (actionArg === '--help' || actionArg === '-h') {
|
|
printUsage();
|
|
return;
|
|
}
|
|
const action = ACTION_ALIASES.get(String(actionArg).toLowerCase());
|
|
if (!action) {
|
|
throw new Error(`Unknown ignores action: ${actionArg}. Run "impeccable ignores --help".`);
|
|
}
|
|
const rest = args.slice(1);
|
|
let out;
|
|
switch (action) {
|
|
case 'list': out = list(cwd); break;
|
|
case 'add-rule': out = addRule(cwd, rest); break;
|
|
case 'add-file': out = addFile(cwd, rest); break;
|
|
case 'add-value': out = addValue(cwd, rest); break;
|
|
case 'remove-rule': out = removeRule(cwd, rest); break;
|
|
case 'remove-file': out = removeFile(cwd, rest); break;
|
|
case 'remove-value': out = removeValue(cwd, rest); break;
|
|
case 'clear': out = clear(cwd, rest); break;
|
|
}
|
|
if (out) console.log(out);
|
|
}
|