From 31dcc687c66ed0037dd4ebfe3ba474e2b8a7b06b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:14:29 +0000 Subject: [PATCH] Sync generated provider output --- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .pi/skills/impeccable/scripts/live-browser.js | 16 +- .pi/skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- 96 files changed, 6400 insertions(+), 32 deletions(-) create mode 100644 .agents/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .claude/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .cursor/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .gemini/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .github/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .grok/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .hermes/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .kiro/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .opencode/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .pi/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .qoder/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .rovodev/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .trae-cn/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .trae/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .vibe/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 plugin/skills/impeccable/scripts/live-browser-ignores.js diff --git a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.agents/skills/impeccable/scripts/live-browser-ignores.js b/.agents/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.agents/skills/impeccable/scripts/live/browser-script-parts.mjs b/.agents/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.agents/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.agents/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.claude/skills/impeccable/scripts/live-browser-ignores.js b/.claude/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.claude/skills/impeccable/scripts/live/browser-script-parts.mjs b/.claude/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.claude/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.claude/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.cursor/skills/impeccable/scripts/live-browser-ignores.js b/.cursor/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.cursor/skills/impeccable/scripts/live/browser-script-parts.mjs b/.cursor/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.cursor/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.cursor/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.gemini/skills/impeccable/scripts/live-browser-ignores.js b/.gemini/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.gemini/skills/impeccable/scripts/live/browser-script-parts.mjs b/.gemini/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.gemini/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.gemini/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.github/skills/impeccable/scripts/live-browser-ignores.js b/.github/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.github/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.github/skills/impeccable/scripts/live/browser-script-parts.mjs b/.github/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.github/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.github/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.grok/skills/impeccable/scripts/live-browser-ignores.js b/.grok/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.grok/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.grok/skills/impeccable/scripts/live-browser.js b/.grok/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.grok/skills/impeccable/scripts/live-browser.js +++ b/.grok/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.grok/skills/impeccable/scripts/live-server.mjs b/.grok/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.grok/skills/impeccable/scripts/live-server.mjs +++ b/.grok/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.grok/skills/impeccable/scripts/live/browser-script-parts.mjs b/.grok/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.grok/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.grok/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.hermes/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.hermes/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.hermes/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.hermes/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.hermes/skills/impeccable/scripts/live-browser-ignores.js b/.hermes/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.hermes/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.hermes/skills/impeccable/scripts/live-browser.js b/.hermes/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.hermes/skills/impeccable/scripts/live-browser.js +++ b/.hermes/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.hermes/skills/impeccable/scripts/live-server.mjs b/.hermes/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.hermes/skills/impeccable/scripts/live-server.mjs +++ b/.hermes/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.hermes/skills/impeccable/scripts/live/browser-script-parts.mjs b/.hermes/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.hermes/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.hermes/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.kiro/skills/impeccable/scripts/live-browser-ignores.js b/.kiro/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.kiro/skills/impeccable/scripts/live/browser-script-parts.mjs b/.kiro/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.kiro/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.kiro/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.opencode/skills/impeccable/scripts/live-browser-ignores.js b/.opencode/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.opencode/skills/impeccable/scripts/live/browser-script-parts.mjs b/.opencode/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.opencode/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.opencode/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.pi/skills/impeccable/scripts/live-browser-ignores.js b/.pi/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.pi/skills/impeccable/scripts/live/browser-script-parts.mjs b/.pi/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.pi/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.pi/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.qoder/skills/impeccable/scripts/live-browser-ignores.js b/.qoder/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.qoder/skills/impeccable/scripts/live/browser-script-parts.mjs b/.qoder/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.qoder/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.qoder/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.rovodev/skills/impeccable/scripts/live-browser-ignores.js b/.rovodev/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.rovodev/skills/impeccable/scripts/live/browser-script-parts.mjs b/.rovodev/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.rovodev/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.rovodev/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.trae-cn/skills/impeccable/scripts/live-browser-ignores.js b/.trae-cn/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.trae-cn/skills/impeccable/scripts/live/browser-script-parts.mjs b/.trae-cn/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.trae-cn/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.trae-cn/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.trae/skills/impeccable/scripts/live-browser-ignores.js b/.trae/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.trae/skills/impeccable/scripts/live/browser-script-parts.mjs b/.trae/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.trae/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.trae/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.vibe/skills/impeccable/scripts/live-browser-ignores.js b/.vibe/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.vibe/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.vibe/skills/impeccable/scripts/live-browser.js b/.vibe/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.vibe/skills/impeccable/scripts/live-browser.js +++ b/.vibe/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.vibe/skills/impeccable/scripts/live-server.mjs b/.vibe/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.vibe/skills/impeccable/scripts/live-server.mjs +++ b/.vibe/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.vibe/skills/impeccable/scripts/live/browser-script-parts.mjs b/.vibe/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.vibe/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.vibe/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs b/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/plugin/skills/impeccable/scripts/live-browser-ignores.js b/plugin/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/plugin/skills/impeccable/scripts/live/browser-script-parts.mjs b/plugin/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/plugin/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/plugin/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || '');