diff --git a/README.md b/README.md index cba046437..33f94ce5a 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,8 @@ The detector catches 44 deterministic issues across AI slop (side-tab borders, p By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution. +For a waiver that should travel with one file instead of the repo config, add an inline comment in the file: ``. The marker works in any comment syntax, scopes to the whole file (or one line with `impeccable-disable-line` / `impeccable-disable-next-line`), and is bypassed by `--no-inline-ignores` or `--no-config`. + Full detector docs: [impeccable.style/docs/detector](https://impeccable.style/docs/detector). ## Supported Tools diff --git a/cli/engine/cli/main.mjs b/cli/engine/cli/main.mjs index 89a53579a..f70027bc8 100644 --- a/cli/engine/cli/main.mjs +++ b/cli/engine/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/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index 477b18fa1..abbdd7b1c 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/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/cli/engine/engines/static-html/detect-html.mjs b/cli/engine/engines/static-html/detect-html.mjs index 9aacb7b6f..7c6748e17 100644 --- a/cli/engine/engines/static-html/detect-html.mjs +++ b/cli/engine/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/cli/engine/shared/inline-ignores.mjs b/cli/engine/shared/inline-ignores.mjs new file mode 100644 index 000000000..e5d64b18a --- /dev/null +++ b/cli/engine/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/scripts/test-suites.mjs b/scripts/test-suites.mjs index 24904e7ea..9818bbfac 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -79,13 +79,14 @@ export const SUITES = { /^scripts\/(benchmark-detector|build-browser-detector|build-extension)\.js$/, /^site\/(pages\/detector|public\/antipattern|data\/anti-patterns-catalog\.js)/, /^tests\/design-system\.test\.mjs$/, - /^tests\/(detect-antipatterns|extension-build|fixtures\/antipatterns)/, + /^tests\/(detect-antipatterns|inline-ignores|extension-build|fixtures\/antipatterns)/, ], commands: [ { runner: 'bun', files: [ 'tests/detect-antipatterns.test.js', + 'tests/inline-ignores.test.mjs', 'tests/lib/detector-bundle.test.js', ], }, diff --git a/site/content/reference/config.md b/site/content/reference/config.md index 946ea6023..15bcf3464 100644 --- a/site/content/reference/config.md +++ b/site/content/reference/config.md @@ -70,6 +70,28 @@ npx impeccable ignores add-value design-system-color "*" --file "src/demo.css" That keeps one intentionally experimental file from teaching the whole project that every undocumented color is acceptable. +## Inline ignore comments + +Config ignores live in `.impeccable/config.json`, which is the right home for repo-wide policy. They do not follow a file out of the repo, though. When a waiver belongs to one file and needs to travel with it (a generated or exported standalone document, an emailed HTML file, a snippet scanned out of context), put the waiver in the file itself: + +```html + +``` + +The directive is comment-syntax-agnostic, so the same marker works in `//`, `/* */`, ``, `#`, and `{/* */}` comments across HTML, CSS, JSX, TSX, Vue, and Svelte. Three scopes are available: + +```css +/* impeccable-disable overused-font */ /* whole file */ +.brand { font-family: Inter } /* impeccable-disable-line overused-font */ +/* impeccable-disable-next-line bounce-easing */ +``` + +List one or more rule ids, comma-separated, or omit them (or use `*`) for every rule. A reason after `:` or `--` is optional and recommended; it is for the diff, and the scanner discards it. Like config ignores, a matched directive suppresses the finding. + +Static HTML findings have no line number, so only whole-file `impeccable-disable` applies to them. That is the standalone-document case this exists for. The line-scoped forms apply to CSS, JSX, TSX, Vue, and Svelte, where findings carry a line. + +Inline directives apply by default. `--no-inline-ignores` turns them off for one run while keeping config ignores; `--no-config` turns off config and inline ignores together. + ## Details when the default path is not enough
diff --git a/site/content/reference/detector.md b/site/content/reference/detector.md index cee2a985b..abddf4b03 100644 --- a/site/content/reference/detector.md +++ b/site/content/reference/detector.md @@ -78,7 +78,13 @@ npx impeccable ignores add-value overused-font Inter --reason "Brand font" npx impeccable ignores add-file "src/legacy/**" ``` -Use [Config and ignores](/docs/config) for the full ignore workflow. +For a waiver that should travel with one file instead of living in the repo config, drop an inline comment in the file itself: + +```html + +``` + +Use [Config and ignores](/docs/config) for the full ignore workflow, including the line-scoped `impeccable-disable-line` and `impeccable-disable-next-line` forms. ## Details when the default path is not enough @@ -96,6 +102,7 @@ Use [Config and ignores](/docs/config) for the full ignore workflow.

By default, detect reads .impeccable/config.json and .impeccable/config.local.json.

It respects detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, and detector.designSystem.enabled.

It does not respect hook.enabled; manual scans still run when the automatic hook is disabled.

+

In-file impeccable-disable* comments are honored too, so a waiver can travel with a file. --no-inline-ignores skips just those; --no-config skips config and inline ignores together.

Use --no-config only when you want a raw detector run with no project config, no detector ignores, and no DESIGN.md context.

diff --git a/site/pages/changelog.astro b/site/pages/changelog.astro index 4fdc45a3a..20fe7e7fa 100644 --- a/site/pages/changelog.astro +++ b/site/pages/changelog.astro @@ -29,6 +29,7 @@ import '../styles/changelog-faq-kinpaku.css';
  • Design hooks for GitHub Copilot. Installing the skill adds a .github/hooks/impeccable.json hook that runs the detector after Copilot edits a UI file and feeds the findings back as a focused design reminder. It covers both the Copilot CLI and the cloud agent, and recognizes every edit path Copilot uses, including apply_patch.
  • Monorepo-aware context. Open the repo root and Impeccable resolves PRODUCT.md and DESIGN.md per app: each child app uses its own context files and falls back to the root files for anything it does not define. /impeccable live detects multiple apps, asks which one to work on, and then runs and stores live state inside that app.
  • +
  • Inline ignore comments. Waive a detector finding with an in-file comment that travels with the file, for exported or standalone documents where .impeccable/config.json is not present. impeccable-disable <rule> covers the whole file; impeccable-disable-line and impeccable-disable-next-line scope to one line. The marker works in any comment syntax, takes an optional reason, and is bypassed with --no-inline-ignores.
diff --git a/skill/reference/hooks.md b/skill/reference/hooks.md index 91d18c0d3..a329dd833 100644 --- a/skill/reference/hooks.md +++ b/skill/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/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index 3aede45c1..32232c88b 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/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/tests/hook.test.mjs b/tests/hook.test.mjs index d52b93842..30631f9b6 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -658,8 +658,9 @@ describe('renderTemplate()', () => { assert.match(text, /not automatically a defect/); assert.match(text, /literal or domain-appropriate motion/); assert.match(text, /Do not change intentional design just to satisfy the hook/); - assert.match(text, /Persist hook ignores only after the user explicitly confirms/); - assert.match(text, /Do not add source comments such as `impeccable: ignore`/); + assert.match(text, /Suppress a finding only after the user explicitly confirms it is intentional/); + assert.match(text, /do not silence a real finding with an inline ignore comment/); + assert.match(text, /inline `impeccable-disable ` comment only when the waiver must travel with a file/); assert.match(text, /ignore-value \.\.\. --shared/); assert.match(text, /ignore-rule overused-font --all-values/); assert.match(text, /\/impeccable hooks ignore-file Card\.tsx/); @@ -937,8 +938,8 @@ rounded: const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.equal(r.exitCode, 0); assert.ok(r.stdout.includes(ENVELOPE_PREFIX)); - assert.match(r.stdout, /No anti-patterns/); - assert.match(r.stdout, /typography hierarchy, spacing rhythm, and color contrast/); + assert.match(r.stdout, /No deterministic design-quality issues found/); + assert.match(r.stdout, /keep following the project design system and the impeccable skill guidance/); assert.equal(r.audit.emitted, true); assert.equal(r.audit.kind, 'clean'); }); @@ -1080,7 +1081,7 @@ rounded: cwd, detector: det, }); - assert.match(withoutDesign.stdout, /No anti-patterns/); + assert.match(withoutDesign.stdout, /No deterministic design-quality issues found/); assert.doesNotMatch(withoutDesign.stdout, /design-system-font/); writeDesignMd(); @@ -1110,7 +1111,7 @@ rounded: detector: designAwareDetector(), }); - assert.match(r.stdout, /No anti-patterns/); + assert.match(r.stdout, /No deterministic design-quality issues found/); assert.doesNotMatch(r.stdout, /design-system-font/); }); @@ -1133,7 +1134,7 @@ rounded: detector: designAwareDetector(), }); - assert.match(r.stdout, /No anti-patterns/); + assert.match(r.stdout, /No deterministic design-quality issues found/); assert.doesNotMatch(r.stdout, /design-system-font/); }); @@ -1153,7 +1154,7 @@ rounded: detector: det, }); - assert.match(r.stdout, /No anti-patterns/); + assert.match(r.stdout, /No deterministic design-quality issues found/); assert.match(r.stdout, /DESIGN\.md is newer than \.impeccable\/design\.json/); assert.match(r.stdout, /\/impeccable document/); }); @@ -1288,10 +1289,29 @@ rounded: detector: { detectHtml, detectText }, }); assert.match(r.stdout, /Design hook findings requiring review/); - assert.doesNotMatch(r.stdout, /No anti-patterns/); + assert.doesNotMatch(r.stdout, /No deterministic design-quality issues found/); assert.ok(r.audit.findings > 0); }); + it('honors an inline impeccable-disable comment so the hook scans the file clean', async () => { + // The hook runs the same engine as `npx impeccable detect`, so an in-file + // waiver suppresses hook findings exactly like a config ignore would. + const flagged = writeFixture('src/Flagged.tsx', 'const css = "font-family: Inter";'); + const flaggedRun = await runHook({ + stdinJson: JSON.stringify(eventFor(flagged)), env: {}, cwd, detector: { detectHtml, detectText }, + }); + assert.match(flaggedRun.stdout, /Design hook findings requiring review/); + assert.ok(flaggedRun.audit.findings > 0); + + const waived = writeFixture('src/Waived.tsx', + 'const css = "font-family: Inter"; // impeccable-disable-line overused-font'); + const waivedRun = await runHook({ + stdinJson: JSON.stringify(eventFor(waived)), env: {}, cwd, detector: { detectHtml, detectText }, + }); + assert.match(waivedRun.stdout, /No deterministic design-quality issues found/); + assert.equal(waivedRun.audit.findings, 0); + }); + it('malformed stdin → silent skip', async () => { const r = await runHook({ stdinJson: '{not json', env: {}, cwd }); assert.equal(r.audit.skipped, 'stdin-malformed'); @@ -1340,8 +1360,8 @@ describe('ALLOWED_EXTS', () => { describe('renderCleanAck() / renderPendingAck()', () => { it('renderCleanAck stays short and ends with the steer line', () => { const text = renderCleanAck('/x/src/App.jsx', { cwd: '/x' }); - assert.match(text, /^\[impeccable@1\] Design hook scanned src\/App\.jsx\. No anti-patterns\./); - assert.match(text, /typography hierarchy, spacing rhythm, and color contrast/); + assert.match(text, /^\[impeccable@1\] Design hook scanned src\/App\.jsx\. No deterministic design-quality issues found\./); + assert.match(text, /keep following the project design system and the impeccable skill guidance/); // Budget guard: should fit comfortably under a single context-message // injection (~200 chars). Hard upper bound 240 chars. assert.ok(text.length < 240, `clean ack too long: ${text.length} chars`); diff --git a/tests/inline-ignores.test.mjs b/tests/inline-ignores.test.mjs new file mode 100644 index 000000000..7c24bde81 --- /dev/null +++ b/tests/inline-ignores.test.mjs @@ -0,0 +1,210 @@ +import { describe, test, expect } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { + parseInlineIgnores, + applyInlineIgnores, + isInlineIgnored, +} from '../cli/engine/shared/inline-ignores.mjs'; +import { detectText, detectHtml } from '../cli/engine/detect-antipatterns.mjs'; + +const CLI = path.resolve('cli/bin/cli.js'); + +function rules(finding) { + return finding.antipattern; +} + +describe('parseInlineIgnores', () => { + test('whole-file directive collects rules', () => { + const d = parseInlineIgnores('/* impeccable-disable overused-font, bounce-easing */'); + expect([...d.file].sort()).toEqual(['bounce-easing', 'overused-font']); + expect(d.line.size).toBe(0); + expect(d.nextLine.size).toBe(0); + }); + + test('bare directive and explicit * both mean every rule', () => { + expect([...parseInlineIgnores('// impeccable-disable').file]).toEqual(['*']); + expect([...parseInlineIgnores('// impeccable-disable *').file]).toEqual(['*']); + }); + + test('disable-line targets its own line, disable-next-line targets the line below', () => { + const content = [ + 'a', // line 1 + 'b /* impeccable-disable-line overused-font */', // line 2 + '// impeccable-disable-next-line side-tab', // line 3 -> targets line 4 + 'd', // line 4 + ].join('\n'); + const d = parseInlineIgnores(content); + expect([...d.line.get(2)]).toEqual(['overused-font']); + expect([...d.nextLine.get(4)]).toEqual(['side-tab']); + }); + + test('strips eslint -- and biome : reasons from the rule list', () => { + expect([...parseInlineIgnores('// impeccable-disable overused-font -- brand font, exported doc').file]) + .toEqual(['overused-font']); + expect([...parseInlineIgnores('# impeccable-disable bounce-easing: intentional bounce').file]) + .toEqual(['bounce-easing']); + }); + + test('strips trailing comment closers across syntaxes', () => { + expect([...parseInlineIgnores('').file]).toEqual(['overused-font']); + expect([...parseInlineIgnores('{/* impeccable-disable overused-font */}').file]).toEqual(['overused-font']); + expect([...parseInlineIgnores('{# impeccable-disable overused-font #}').file]).toEqual(['overused-font']); + }); + + test('directive keyword is case-insensitive (fast-path matches the regex)', () => { + expect([...parseInlineIgnores('// Impeccable-Disable overused-font').file]).toEqual(['overused-font']); + expect([...parseInlineIgnores('/* IMPECCABLE-DISABLE-LINE side-tab */').line.get(1)]).toEqual(['side-tab']); + }); + + test('no directive present is a cheap no-op', () => { + const d = parseInlineIgnores('.a { color: red }'); + expect(d.file.size).toBe(0); + expect(d.line.size).toBe(0); + expect(d.nextLine.size).toBe(0); + }); +}); + +describe('applyInlineIgnores / isInlineIgnored', () => { + const findings = [ + { antipattern: 'overused-font', line: 5 }, + { antipattern: 'side-tab', line: 5 }, + { antipattern: 'overused-font', line: 0 }, // no line (static-HTML shape) + ]; + + test('whole-file directive drops every matching finding regardless of line', () => { + const out = applyInlineIgnores(findings, '/* impeccable-disable overused-font */'); + expect(out.map(rules)).toEqual(['side-tab']); + }); + + test('* drops everything', () => { + expect(applyInlineIgnores(findings, '// impeccable-disable *')).toEqual([]); + }); + + test('line-scoped directive only affects the matching line and rule', () => { + const content = ['', '', '', '', 'x /* impeccable-disable-line overused-font */'].join('\n'); + const out = applyInlineIgnores(findings, content); + // the line-5 overused-font goes; side-tab on line 5 and the line-less one stay + expect(out.map(rules).sort()).toEqual(['overused-font', 'side-tab']); + expect(out.some((f) => f.antipattern === 'overused-font' && f.line === 5)).toBe(false); + }); + + test('returns the input untouched when there are no directives', () => { + const out = applyInlineIgnores(findings, '.a {}'); + expect(out).toBe(findings); + }); + + test('isInlineIgnored never matches a line-scoped directive for a line-less finding', () => { + const d = parseInlineIgnores('x /* impeccable-disable-line overused-font */'); + expect(isInlineIgnored({ antipattern: 'overused-font', line: 0 }, d)).toBe(false); + }); +}); + +describe('detectText honors inline directives', () => { + const opts = { providers: [] }; + + test('disable-line suppresses a same-line finding', () => { + const flagged = detectText('.a { font-family: Inter; }', 'a.css', opts); + expect(flagged.some((f) => f.antipattern === 'overused-font')).toBe(true); + + const waived = detectText('.a { font-family: Inter; } /* impeccable-disable-line overused-font */', 'a.css', opts); + expect(waived.some((f) => f.antipattern === 'overused-font')).toBe(false); + }); + + test('disable-next-line suppresses the finding on the following line', () => { + const content = '/* impeccable-disable-next-line overused-font */\n.a { font-family: Inter; }'; + const waived = detectText(content, 'a.css', opts); + expect(waived.some((f) => f.antipattern === 'overused-font')).toBe(false); + }); + + test('whole-file directive suppresses regardless of where the finding is', () => { + const content = '/* impeccable-disable overused-font */\n.a {}\n.b { font-family: Inter; }'; + const waived = detectText(content, 'a.css', opts); + expect(waived.some((f) => f.antipattern === 'overused-font')).toBe(false); + }); + + test('inlineIgnores:false bypasses the directive', () => { + const content = '.a { font-family: Inter; } /* impeccable-disable-line overused-font */'; + const raw = detectText(content, 'a.css', { providers: [], inlineIgnores: false }); + expect(raw.some((f) => f.antipattern === 'overused-font')).toBe(true); + }); + + test('line keys align with detector line numbers on CRLF endings', () => { + // detectText numbers lines with split('\n'); parseInlineIgnores must match. + const content = '.a { font-family: Inter; }\r\n.b { font-family: Roboto; } /* impeccable-disable-line overused-font */'; + const out = detectText(content, 'a.css', opts); + const fonts = out.filter((f) => f.antipattern === 'overused-font').map((f) => f.line); + expect(fonts).toEqual([1]); // Inter on line 1 stays; Roboto on line 2 is waived + }); + + test('a directive for one rule leaves other findings intact', () => { + const content = '.a { font-family: Inter; } /* impeccable-disable-line side-tab */'; + const out = detectText(content, 'a.css', opts); + expect(out.some((f) => f.antipattern === 'overused-font')).toBe(true); + }); +}); + +describe('detectHtml honors whole-file directives (line-less findings)', () => { + const page = (extra = '') => `${extra} + +

Some real paragraph text here for the typography pass.

+

Heading

Sub

`; + + test('overused-font fires without a directive', async () => { + const flagged = await detectHtml(await writeTmp(page()), { providers: [] }); + expect(flagged.some((f) => f.antipattern === 'overused-font')).toBe(true); + }); + + test('whole-file directive in an HTML comment suppresses it', async () => { + const file = await writeTmp(page('')); + const waived = await detectHtml(file, { providers: [] }); + expect(waived.some((f) => f.antipattern === 'overused-font')).toBe(false); + }); + + test('inlineIgnores:false bypasses it', async () => { + const file = await writeTmp(page('')); + const raw = await detectHtml(file, { providers: [], inlineIgnores: false }); + expect(raw.some((f) => f.antipattern === 'overused-font')).toBe(true); + }); +}); + +describe('detect CLI end-to-end', () => { + function run(args) { + return spawnSync(process.execPath, [CLI, 'detect', ...args], { encoding: 'utf-8' }); + } + + test('inline directive is honored by default, --no-inline-ignores and --no-config bypass it', async () => { + const file = await writeTmp( + '\n' + + '\n' + + '

Paragraph copy for the typography analyzer to read.

H

S

', + '.html', + ); + + const honored = run([file, '--json', '--no-design-system']); + expect(JSON.parse(honored.stdout).some((f) => f.antipattern === 'overused-font')).toBe(false); + + const bypassed = run([file, '--json', '--no-design-system', '--no-inline-ignores']); + expect(JSON.parse(bypassed.stdout).some((f) => f.antipattern === 'overused-font')).toBe(true); + + const rawConfig = run([file, '--json', '--no-config']); + expect(JSON.parse(rawConfig.stdout).some((f) => f.antipattern === 'overused-font')).toBe(true); + }); +}); + +let tmpDir; +async function writeTmp(content, ext = '.html') { + if (!tmpDir) tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-inline-')); + const file = path.join(tmpDir, `f${Math.abs(hash(content))}${ext}`); + fs.writeFileSync(file, content); + return file; +} + +function hash(str) { + let h = 0; + for (let i = 0; i < str.length; i++) h = (Math.imul(31, h) + str.charCodeAt(i)) | 0; + return h; +}