diff --git a/.agents/skills/impeccable/reference/hooks.md b/.agents/skills/impeccable/reference/hooks.md index ef384ad7a..8d22e33ff 100644 --- a/.agents/skills/impeccable/reference/hooks.md +++ b/.agents/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/.agents/skills/impeccable/scripts/detector/cli/main.mjs b/.agents/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/.agents/skills/impeccable/scripts/detector/cli/main.mjs +++ b/.agents/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.agents/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/.agents/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/.agents/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/.agents/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); } diff --git a/.claude/skills/impeccable/reference/hooks.md b/.claude/skills/impeccable/reference/hooks.md index c57f6251b..9f52c7897 100644 --- a/.claude/skills/impeccable/reference/hooks.md +++ b/.claude/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/.claude/skills/impeccable/scripts/detector/cli/main.mjs b/.claude/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/.claude/skills/impeccable/scripts/detector/cli/main.mjs +++ b/.claude/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.claude/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/.claude/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/.claude/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/.claude/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); } diff --git a/.cursor/skills/impeccable/reference/hooks.md b/.cursor/skills/impeccable/reference/hooks.md index 8b2f2fcce..dd74082b1 100644 --- a/.cursor/skills/impeccable/reference/hooks.md +++ b/.cursor/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/.cursor/skills/impeccable/scripts/detector/cli/main.mjs b/.cursor/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/.cursor/skills/impeccable/scripts/detector/cli/main.mjs +++ b/.cursor/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.cursor/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/.cursor/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/.cursor/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/.cursor/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); } diff --git a/.gemini/skills/impeccable/reference/hooks.md b/.gemini/skills/impeccable/reference/hooks.md index afa0ff25d..56c9a8ea1 100644 --- a/.gemini/skills/impeccable/reference/hooks.md +++ b/.gemini/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/.gemini/skills/impeccable/scripts/detector/cli/main.mjs b/.gemini/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/.gemini/skills/impeccable/scripts/detector/cli/main.mjs +++ b/.gemini/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.gemini/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/.gemini/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/.gemini/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/.gemini/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); } diff --git a/.github/skills/impeccable/reference/hooks.md b/.github/skills/impeccable/reference/hooks.md index 1edc4e93b..72117dd96 100644 --- a/.github/skills/impeccable/reference/hooks.md +++ b/.github/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/.github/skills/impeccable/scripts/detector/cli/main.mjs b/.github/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/.github/skills/impeccable/scripts/detector/cli/main.mjs +++ b/.github/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.github/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/.github/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/.github/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/.github/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); } diff --git a/.kiro/skills/impeccable/reference/hooks.md b/.kiro/skills/impeccable/reference/hooks.md index 789d47228..7088a4528 100644 --- a/.kiro/skills/impeccable/reference/hooks.md +++ b/.kiro/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/.kiro/skills/impeccable/scripts/detector/cli/main.mjs b/.kiro/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/.kiro/skills/impeccable/scripts/detector/cli/main.mjs +++ b/.kiro/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.kiro/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/.kiro/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/.kiro/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/.kiro/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); } diff --git a/.opencode/skills/impeccable/reference/hooks.md b/.opencode/skills/impeccable/reference/hooks.md index 5f38a0912..19c6a114d 100644 --- a/.opencode/skills/impeccable/reference/hooks.md +++ b/.opencode/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/.opencode/skills/impeccable/scripts/detector/cli/main.mjs b/.opencode/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/.opencode/skills/impeccable/scripts/detector/cli/main.mjs +++ b/.opencode/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.opencode/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/.opencode/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/.opencode/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/.opencode/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); } diff --git a/.pi/skills/impeccable/reference/hooks.md b/.pi/skills/impeccable/reference/hooks.md index 72f5c51b8..5d6e128a8 100644 --- a/.pi/skills/impeccable/reference/hooks.md +++ b/.pi/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/.pi/skills/impeccable/scripts/detector/cli/main.mjs b/.pi/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/.pi/skills/impeccable/scripts/detector/cli/main.mjs +++ b/.pi/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.pi/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/.pi/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/.pi/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/.pi/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); } diff --git a/.qoder/skills/impeccable/reference/hooks.md b/.qoder/skills/impeccable/reference/hooks.md index a18baf65a..1a0143a03 100644 --- a/.qoder/skills/impeccable/reference/hooks.md +++ b/.qoder/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/.qoder/skills/impeccable/scripts/detector/cli/main.mjs b/.qoder/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/.qoder/skills/impeccable/scripts/detector/cli/main.mjs +++ b/.qoder/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.qoder/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/.qoder/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/.qoder/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/.qoder/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); } diff --git a/.rovodev/skills/impeccable/reference/hooks.md b/.rovodev/skills/impeccable/reference/hooks.md index 2a5ced2f8..074300c8b 100644 --- a/.rovodev/skills/impeccable/reference/hooks.md +++ b/.rovodev/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/.rovodev/skills/impeccable/scripts/detector/cli/main.mjs b/.rovodev/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/.rovodev/skills/impeccable/scripts/detector/cli/main.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/.rovodev/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/.rovodev/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); } diff --git a/.trae-cn/skills/impeccable/reference/hooks.md b/.trae-cn/skills/impeccable/reference/hooks.md index 09f21accf..5d8440367 100644 --- a/.trae-cn/skills/impeccable/reference/hooks.md +++ b/.trae-cn/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/.trae-cn/skills/impeccable/scripts/detector/cli/main.mjs b/.trae-cn/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/cli/main.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/.trae-cn/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/.trae-cn/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); } diff --git a/.trae/skills/impeccable/reference/hooks.md b/.trae/skills/impeccable/reference/hooks.md index d0a2115a1..5a8ec9d80 100644 --- a/.trae/skills/impeccable/reference/hooks.md +++ b/.trae/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/.trae/skills/impeccable/scripts/detector/cli/main.mjs b/.trae/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/.trae/skills/impeccable/scripts/detector/cli/main.mjs +++ b/.trae/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/.trae/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/.trae/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/.trae/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/.trae/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); } diff --git a/plugin/skills/impeccable/reference/hooks.md b/plugin/skills/impeccable/reference/hooks.md index c57f6251b..9f52c7897 100644 --- a/plugin/skills/impeccable/reference/hooks.md +++ b/plugin/skills/impeccable/reference/hooks.md @@ -51,7 +51,7 @@ Prefer the narrowest exception: - For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file ` for the current file. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. -- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism. +- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable ` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it. Example value-specific exception: diff --git a/plugin/skills/impeccable/scripts/detector/cli/main.mjs b/plugin/skills/impeccable/scripts/detector/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/plugin/skills/impeccable/scripts/detector/cli/main.mjs +++ b/plugin/skills/impeccable/scripts/detector/cli/main.mjs @@ -93,7 +93,9 @@ Options: --quiet In text mode, only print the final findings count --gpt Also report GPT-specific provider tells (off by default) --gemini Also report Gemini-specific provider tells (off by default) - --no-config Do not apply project config, detector ignores, or DESIGN.md + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments --no-design-system Do not load local DESIGN.md / .impeccable/design.json context --help Show this help message @@ -102,6 +104,14 @@ Project config: settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled. +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + Detection modes: HTML files Static HTML/CSS analysis (default, catches linked CSS) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) @@ -143,7 +153,12 @@ async function detectCli() { if (args.includes('--gemini')) providers.push('gemini'); const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; - const scanOptions = designSystem ? { providers, designSystem } : { providers }; + // Inline `impeccable-disable*` waivers are part of the scanned file, so they + // apply by default. `--no-config` (raw scan) and the dedicated + // `--no-inline-ignores` both turn them off. + const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); + const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled }; + if (designSystem) scanOptions.designSystem = designSystem; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } diff --git a/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs b/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +++ b/plugin/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs @@ -2,6 +2,7 @@ import { GENERIC_FONTS } from '../../shared/constants.mjs'; import { isNeutralColor } from '../../shared/color.mjs'; import { checkSourceDesignSystem } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { filterByProviders } from '../../registry/antipatterns.mjs'; import { profileFindings, profileStep } from '../../profile/profiler.mjs'; @@ -549,7 +550,10 @@ function detectText(content, filePath, options = {}) { } } - return filterByProviders(deduped, options?.providers); + const byProvider = filterByProviders(deduped, options?.providers); + // Inline `impeccable-disable*` waivers travel with the file; honor them unless + // explicitly bypassed (`--no-config` / `--no-inline-ignores`). + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content); } export { diff --git a/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs @@ -8,6 +8,7 @@ import { mergeDesignSystemFindings, } from '../../design-system.mjs'; import { isFullPage } from '../../shared/page.mjs'; +import { applyInlineIgnores } from '../../shared/inline-ignores.mjs'; import { finding } from '../../findings.mjs'; import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { @@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) { } } - return filterByProviders(findings, options.providers); + const byProvider = filterByProviders(findings, options.providers); + // Static-HTML findings carry no line number, so only whole-file + // `impeccable-disable` directives apply here — exactly the standalone-document + // waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`. + return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html); } export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml }; diff --git a/plugin/skills/impeccable/scripts/detector/shared/inline-ignores.mjs b/plugin/skills/impeccable/scripts/detector/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/plugin/skills/impeccable/scripts/detector/shared/inline-ignores.mjs @@ -0,0 +1,148 @@ +/** + * Inline, in-file ignore directives — eslint-disable-style waivers that live at + * the point they apply and travel with the artifact instead of (or alongside) + * an ignore in `.impeccable/config.json`. + * + * A config ignore is the right default for repo-wide policy. This complements it + * for the one case config can't cover: a waiver that belongs to a single file and + * needs to follow that file when it leaves the repo — a generated/exported + * standalone document, an emailed HTML file, a snippet scanned out of context. + * + * Comment-syntax-agnostic: the directive is a raw token matched anywhere on a + * line, so the same marker works across every comment style impeccable scans — + * `//`, `/* *\/`, ``, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers + * are stripped before the rule list is parsed. + * + * Syntax (reason optional; eslint `--` or biome `:` separator): + * + * impeccable-disable [, ...] [-- reason] whole file + * impeccable-disable-line ... [-- reason] the same line + * impeccable-disable-next-line ... [-- reason] the following line + * impeccable-disable bare / `*` = every rule + * + * Examples: + * + * + * .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/ + * // impeccable-disable-next-line bounce-easing: intentional playful affordance + * + * Behavior is suppression, for parity with config ignores: a matched directive + * drops the finding. The inline reason is self-documenting in the diff; it is not + * required and is discarded at scan time (only used here to keep reason words out + * of the parsed rule list). + */ + +const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi; + +// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't +// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the +// space before the closer. `--+>` covers `-->` and any longer dash run. +const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/; + +function normalizeRule(token) { + return String(token || '').trim().toLowerCase(); +} + +// Split the directive remainder into rule tokens, dropping any human reason that +// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever +// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:` +// are unambiguous separators. +function parseRuleList(remainder) { + let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim(); + // Cut off a human reason at the first `--` (eslint) or `:` (biome) separator. + const reasonSep = text.match(/\s*(?:--+|:)\s*/); + if (reasonSep) text = text.slice(0, reasonSep.index); + const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean); + if (tokens.length === 0 || tokens.includes('*')) return ['*']; + return tokens; +} + +function addRules(set, rules) { + for (const rule of rules) set.add(rule); +} + +function getSet(map, key) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; +} + +/** + * Parse every inline ignore directive in a file's raw text. + * + * Returns sets keyed by the 1-based line the directive *targets* so matching is a + * direct lookup: + * - file: rules disabled for the whole file + * - line: line -> rules disabled on that exact line (disable-line) + * - nextLine: line -> rules disabled on that line (disable-next-line on line-1) + * + * `*` in any set means "every rule". + */ +function parseInlineIgnores(content) { + const result = { file: new Set(), line: new Map(), nextLine: new Map() }; + const text = typeof content === 'string' ? content : ''; + // Cheap bail-out: the substring must be present for any directive to exist. + // Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`). + if (!/impeccable-disable/i.test(text)) return result; + + // Split on `\n` only, exactly as detectText numbers lines, so directive line + // keys line up with finding `line` values (incl. on `\r`-only line endings). + // The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is + // never captured into the rule list. + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + DIRECTIVE_RE.lastIndex = 0; + let m; + while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) { + const variant = m[1].toLowerCase(); + const rules = parseRuleList(m[2]); + if (variant === 'disable') { + addRules(result.file, rules); + } else if (variant === 'disable-line') { + addRules(getSet(result.line, i + 1), rules); + } else { + // disable-next-line on line i+1 targets line i+2. + addRules(getSet(result.nextLine, i + 2), rules); + } + } + } + return result; +} + +function setMatches(set, rule) { + return Boolean(set) && (set.has('*') || set.has(rule)); +} + +function isInlineIgnored(finding, directives) { + const rule = normalizeRule(finding && finding.antipattern); + if (!rule) return false; + if (setMatches(directives.file, rule)) return true; + const line = Number(finding && finding.line) || 0; + if (line > 0) { + if (setMatches(directives.line.get(line), rule)) return true; + if (setMatches(directives.nextLine.get(line), rule)) return true; + } + return false; +} + +function hasDirectives(directives) { + return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0; +} + +/** + * Drop findings waived by an inline directive in the same file's source text. + * Findings without a usable line number (e.g. static-HTML page-level findings) + * are only matched by whole-file directives — which is the standalone-document + * case this primitive exists for. + */ +function applyInlineIgnores(findings, content) { + if (!Array.isArray(findings) || findings.length === 0) return findings; + const directives = parseInlineIgnores(content); + if (!hasDirectives(directives)) return findings; + return findings.filter((finding) => !isInlineIgnored(finding, directives)); +} + +export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored }; diff --git a/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/scripts/hook-lib.mjs @@ -1301,12 +1301,12 @@ export function setDetectorForTesting(impl) { // session" so the model knows it's a re-mind, not a new finding. // ──────────────────────────────────────────────────────────────────────── -const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.'; +const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.'; export function renderCleanAck(filePath, opts = {}) { const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); - return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`; + return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`; } export function renderPendingAck(filePath, knownFindings, opts = {}) { @@ -1362,7 +1362,7 @@ function directiveFooter(display, opts = {}) { '', 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.', '', - `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, + `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable \` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule \` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`, ].join('\n'); }