mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
ignore-value stored exact values for rules that cannot extract one, so the entries never matched. Refuse them and point at "*" --file. AI assistance: implemented with Cursor Grok 4.6. Co-authored-by: Cursor <cursoragent@cursor.com>
356 lines
14 KiB
JavaScript
356 lines
14 KiB
JavaScript
import path from 'node:path';
|
|
|
|
import {
|
|
getConfigPath,
|
|
getLocalConfigPath,
|
|
normalizeIgnoreValue,
|
|
readDetectionConfig,
|
|
readRawDetectionConfig,
|
|
writeDetectionConfig,
|
|
extractFindingIgnoreValue,
|
|
} 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);
|
|
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 <glob> to suppress it in matching files.`);
|
|
}
|
|
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);
|
|
}
|