diff --git a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.agents/skills/impeccable/scripts/lib/live-path-globs.mjs b/.agents/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.agents/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.agents/skills/impeccable/scripts/live-inject.mjs b/.agents/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.agents/skills/impeccable/scripts/live-inject.mjs +++ b/.agents/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.agents/skills/impeccable/scripts/live.mjs b/.agents/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.agents/skills/impeccable/scripts/live.mjs +++ b/.agents/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.agents/skills/impeccable/scripts/live/project-ignores.mjs b/.agents/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.agents/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.agents/skills/impeccable/scripts/serve-question.mjs b/.agents/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.agents/skills/impeccable/scripts/serve-question.mjs +++ b/.agents/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.claude/skills/impeccable/scripts/lib/live-path-globs.mjs b/.claude/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.claude/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.claude/skills/impeccable/scripts/live-inject.mjs b/.claude/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.claude/skills/impeccable/scripts/live-inject.mjs +++ b/.claude/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.claude/skills/impeccable/scripts/live.mjs b/.claude/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.claude/skills/impeccable/scripts/live.mjs +++ b/.claude/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.claude/skills/impeccable/scripts/live/project-ignores.mjs b/.claude/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.claude/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.claude/skills/impeccable/scripts/serve-question.mjs b/.claude/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.claude/skills/impeccable/scripts/serve-question.mjs +++ b/.claude/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.cursor/skills/impeccable/scripts/lib/live-path-globs.mjs b/.cursor/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.cursor/skills/impeccable/scripts/live-inject.mjs b/.cursor/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.cursor/skills/impeccable/scripts/live-inject.mjs +++ b/.cursor/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.cursor/skills/impeccable/scripts/live.mjs b/.cursor/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.cursor/skills/impeccable/scripts/live.mjs +++ b/.cursor/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.cursor/skills/impeccable/scripts/live/project-ignores.mjs b/.cursor/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.cursor/skills/impeccable/scripts/serve-question.mjs b/.cursor/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.cursor/skills/impeccable/scripts/serve-question.mjs +++ b/.cursor/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.gemini/skills/impeccable/scripts/lib/live-path-globs.mjs b/.gemini/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.gemini/skills/impeccable/scripts/live-inject.mjs b/.gemini/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.gemini/skills/impeccable/scripts/live-inject.mjs +++ b/.gemini/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.gemini/skills/impeccable/scripts/live.mjs b/.gemini/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.gemini/skills/impeccable/scripts/live.mjs +++ b/.gemini/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.gemini/skills/impeccable/scripts/live/project-ignores.mjs b/.gemini/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.gemini/skills/impeccable/scripts/serve-question.mjs b/.gemini/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.gemini/skills/impeccable/scripts/serve-question.mjs +++ b/.gemini/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.github/skills/impeccable/scripts/lib/live-path-globs.mjs b/.github/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.github/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.github/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.github/skills/impeccable/scripts/live-inject.mjs b/.github/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.github/skills/impeccable/scripts/live-inject.mjs +++ b/.github/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.github/skills/impeccable/scripts/live.mjs b/.github/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.github/skills/impeccable/scripts/live.mjs +++ b/.github/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.github/skills/impeccable/scripts/live/project-ignores.mjs b/.github/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.github/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.github/skills/impeccable/scripts/serve-question.mjs b/.github/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.github/skills/impeccable/scripts/serve-question.mjs +++ b/.github/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.grok/skills/impeccable/scripts/hook-lib.mjs b/.grok/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.grok/skills/impeccable/scripts/hook-lib.mjs +++ b/.grok/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.grok/skills/impeccable/scripts/lib/live-path-globs.mjs b/.grok/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.grok/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.grok/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.grok/skills/impeccable/scripts/live-browser.js +++ b/.grok/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.grok/skills/impeccable/scripts/live-inject.mjs b/.grok/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.grok/skills/impeccable/scripts/live-inject.mjs +++ b/.grok/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.grok/skills/impeccable/scripts/live-server.mjs b/.grok/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.grok/skills/impeccable/scripts/live-server.mjs +++ b/.grok/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.grok/skills/impeccable/scripts/live.mjs b/.grok/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.grok/skills/impeccable/scripts/live.mjs +++ b/.grok/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.grok/skills/impeccable/scripts/live/project-ignores.mjs b/.grok/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.grok/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.grok/skills/impeccable/scripts/serve-question.mjs b/.grok/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.grok/skills/impeccable/scripts/serve-question.mjs +++ b/.grok/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.hermes/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.hermes/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.hermes/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.hermes/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.hermes/skills/impeccable/scripts/hook-lib.mjs b/.hermes/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.hermes/skills/impeccable/scripts/hook-lib.mjs +++ b/.hermes/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.hermes/skills/impeccable/scripts/lib/live-path-globs.mjs b/.hermes/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.hermes/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.hermes/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.hermes/skills/impeccable/scripts/live-browser.js +++ b/.hermes/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.hermes/skills/impeccable/scripts/live-inject.mjs b/.hermes/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.hermes/skills/impeccable/scripts/live-inject.mjs +++ b/.hermes/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.hermes/skills/impeccable/scripts/live-server.mjs b/.hermes/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.hermes/skills/impeccable/scripts/live-server.mjs +++ b/.hermes/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.hermes/skills/impeccable/scripts/live.mjs b/.hermes/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.hermes/skills/impeccable/scripts/live.mjs +++ b/.hermes/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.hermes/skills/impeccable/scripts/live/project-ignores.mjs b/.hermes/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.hermes/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.hermes/skills/impeccable/scripts/serve-question.mjs b/.hermes/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.hermes/skills/impeccable/scripts/serve-question.mjs +++ b/.hermes/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.kiro/skills/impeccable/scripts/lib/live-path-globs.mjs b/.kiro/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.kiro/skills/impeccable/scripts/live-inject.mjs b/.kiro/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.kiro/skills/impeccable/scripts/live-inject.mjs +++ b/.kiro/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.kiro/skills/impeccable/scripts/live.mjs b/.kiro/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.kiro/skills/impeccable/scripts/live.mjs +++ b/.kiro/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.kiro/skills/impeccable/scripts/live/project-ignores.mjs b/.kiro/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.kiro/skills/impeccable/scripts/serve-question.mjs b/.kiro/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.kiro/skills/impeccable/scripts/serve-question.mjs +++ b/.kiro/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.opencode/skills/impeccable/scripts/lib/live-path-globs.mjs b/.opencode/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.opencode/skills/impeccable/scripts/live-inject.mjs b/.opencode/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.opencode/skills/impeccable/scripts/live-inject.mjs +++ b/.opencode/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.opencode/skills/impeccable/scripts/live.mjs b/.opencode/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.opencode/skills/impeccable/scripts/live.mjs +++ b/.opencode/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.opencode/skills/impeccable/scripts/live/project-ignores.mjs b/.opencode/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.opencode/skills/impeccable/scripts/serve-question.mjs b/.opencode/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.opencode/skills/impeccable/scripts/serve-question.mjs +++ b/.opencode/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.pi/skills/impeccable/scripts/lib/live-path-globs.mjs b/.pi/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.pi/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.pi/skills/impeccable/scripts/live-inject.mjs b/.pi/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.pi/skills/impeccable/scripts/live-inject.mjs +++ b/.pi/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.pi/skills/impeccable/scripts/live.mjs b/.pi/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.pi/skills/impeccable/scripts/live.mjs +++ b/.pi/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.pi/skills/impeccable/scripts/live/project-ignores.mjs b/.pi/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.pi/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.pi/skills/impeccable/scripts/serve-question.mjs b/.pi/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.pi/skills/impeccable/scripts/serve-question.mjs +++ b/.pi/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.qoder/skills/impeccable/scripts/lib/live-path-globs.mjs b/.qoder/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.qoder/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.qoder/skills/impeccable/scripts/live-inject.mjs b/.qoder/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.qoder/skills/impeccable/scripts/live-inject.mjs +++ b/.qoder/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.qoder/skills/impeccable/scripts/live.mjs b/.qoder/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.qoder/skills/impeccable/scripts/live.mjs +++ b/.qoder/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.qoder/skills/impeccable/scripts/live/project-ignores.mjs b/.qoder/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.qoder/skills/impeccable/scripts/serve-question.mjs b/.qoder/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.qoder/skills/impeccable/scripts/serve-question.mjs +++ b/.qoder/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.rovodev/skills/impeccable/scripts/lib/live-path-globs.mjs b/.rovodev/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.rovodev/skills/impeccable/scripts/live-inject.mjs b/.rovodev/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.rovodev/skills/impeccable/scripts/live-inject.mjs +++ b/.rovodev/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.rovodev/skills/impeccable/scripts/live.mjs b/.rovodev/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.rovodev/skills/impeccable/scripts/live.mjs +++ b/.rovodev/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.rovodev/skills/impeccable/scripts/live/project-ignores.mjs b/.rovodev/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.rovodev/skills/impeccable/scripts/serve-question.mjs b/.rovodev/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.rovodev/skills/impeccable/scripts/serve-question.mjs +++ b/.rovodev/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } 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..febf7f297 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); 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..7df671eb5 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.trae-cn/skills/impeccable/scripts/lib/live-path-globs.mjs b/.trae-cn/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.trae-cn/skills/impeccable/scripts/live-inject.mjs b/.trae-cn/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.trae-cn/skills/impeccable/scripts/live-inject.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.trae-cn/skills/impeccable/scripts/live.mjs b/.trae-cn/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.trae-cn/skills/impeccable/scripts/live.mjs +++ b/.trae-cn/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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-cn/skills/impeccable/scripts/live/project-ignores.mjs b/.trae-cn/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.trae-cn/skills/impeccable/scripts/serve-question.mjs b/.trae-cn/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.trae-cn/skills/impeccable/scripts/serve-question.mjs +++ b/.trae-cn/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.trae/skills/impeccable/scripts/lib/live-path-globs.mjs b/.trae/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.trae/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.trae/skills/impeccable/scripts/live-inject.mjs b/.trae/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.trae/skills/impeccable/scripts/live-inject.mjs +++ b/.trae/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.trae/skills/impeccable/scripts/live.mjs b/.trae/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.trae/skills/impeccable/scripts/live.mjs +++ b/.trae/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.trae/skills/impeccable/scripts/live/project-ignores.mjs b/.trae/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.trae/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.trae/skills/impeccable/scripts/serve-question.mjs b/.trae/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.trae/skills/impeccable/scripts/serve-question.mjs +++ b/.trae/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/.vibe/skills/impeccable/scripts/hook-lib.mjs b/.vibe/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.vibe/skills/impeccable/scripts/hook-lib.mjs +++ b/.vibe/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.vibe/skills/impeccable/scripts/lib/live-path-globs.mjs b/.vibe/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.vibe/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/.vibe/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/.vibe/skills/impeccable/scripts/live-browser.js +++ b/.vibe/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.vibe/skills/impeccable/scripts/live-inject.mjs b/.vibe/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.vibe/skills/impeccable/scripts/live-inject.mjs +++ b/.vibe/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.vibe/skills/impeccable/scripts/live-server.mjs b/.vibe/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/.vibe/skills/impeccable/scripts/live-server.mjs +++ b/.vibe/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.vibe/skills/impeccable/scripts/live.mjs b/.vibe/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.vibe/skills/impeccable/scripts/live.mjs +++ b/.vibe/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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/.vibe/skills/impeccable/scripts/live/project-ignores.mjs b/.vibe/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/.vibe/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/.vibe/skills/impeccable/scripts/serve-question.mjs b/.vibe/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/.vibe/skills/impeccable/scripts/serve-question.mjs +++ b/.vibe/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index c8f683c81..62f5b87f1 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -8298,7 +8298,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8501,6 +8513,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8718,6 +8843,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8771,7 +8902,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs b/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..febf7f297 100644 --- a/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1472,7 +1472,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -1675,6 +1687,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -1892,6 +2017,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -1945,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..7df671eb5 100644 --- a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8131,7 +8131,19 @@ if (IS_BROWSER) { return findings; } + // A page matched by detector.ignoreFiles is waived wholesale: every scan + // stage answers empty so the badge and toast read zero. Mirrors + // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live + // overlay resolves the globs per page (live-browser-ignores.js) and + // forwards the verdict as config.skipScan. + function skipScanActive() { + return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true; + } + function collectBrowserFindings() { + if (skipScanActive()) { + return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; + } const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -8334,6 +8346,119 @@ 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. One CLI matcher is not mirrored here: the motion extractor + // (a value-scoped bounce-easing waiver only matches when the finding + // carries ignoreValue directly). The CLI's [?&]family= URL fallback is + // also omitted on purpose: browser findings for these rules always + // carry ignoreValue or a "Primary font:" / "Google Fonts:" / + // font-family sentence, so it is unreachable here. + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + // The CLI routes bounce-easing through extractMotionIgnoreValue and + // never the font regexes; without a direct ignoreValue there is no + // value to match, so do not invent one from unrelated CSS text. + if ((f.type || f.id) === 'bounce-easing') return ''; + 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 ''; + }; + // design-system-color compares by color value, not by spelling: the + // browser reports computed rgb(...) strings while waivers are usually + // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in + // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms; + // hsl stays CLI-only. + const _colorKey = (value) => { + const text = String(value || '').trim().toLowerCase(); + const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1]; + const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16)); + return `${r},${g},${b},${a}`; + } + const rgb = text.match(/^rgba?\((.*)\)$/); + if (!rgb) return ''; + const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / '); + let parts; + if (body.includes(',')) { + parts = body.split(',').map(p => p.trim()).filter(Boolean); + const last = parts[parts.length - 1]; + if (last && last.includes('/')) { + parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)]; + } + } else { + parts = body.split(/\s+/).filter(p => p && p !== '/'); + } + if (parts.length < 3 || parts.length > 4) return ''; + const channel = (raw, isAlpha) => { + const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/); + if (!m) return null; + let v = parseFloat(m[1]); + if (m[2]) v = isAlpha ? v / 100 : v * 2.55; + const max = isAlpha ? 1 : 255; + if (!Number.isFinite(v) || v < 0 || v > max) return null; + return isAlpha ? v : Math.round(v); + }; + const r = channel(parts[0], false); + const g = channel(parts[1], false); + const b = channel(parts[2], false); + const a = parts[3] === undefined ? 1 : channel(parts[3], true); + if ([r, g, b, a].some(v => v === null)) return ''; + return `${r},${g},${b},${Math.round(a * 255)}`; + }; + 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 + || (rule === 'design-system-color' + && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(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), @@ -8551,6 +8676,12 @@ if (IS_BROWSER) { async function collectBrowserFindingsAsync(options = {}, runtime = {}) { const collected = collectBrowserFindings(); + // The visual pass walks the DOM on its own; on a skipScan page it would + // repopulate the emptied scan, so it is skipped with everything else. + if (skipScanActive()) { + lastVisualContrastAnalyses = []; + return { ...collected, allFindings: [], visualContrastAnalyses: [] }; + } await addVisualContrastFindings(collected.groupMap, options, runtime); return { ...collected, @@ -8604,7 +8735,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/plugin/skills/impeccable/scripts/lib/live-path-globs.mjs b/plugin/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/plugin/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} 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..1c8514c7b --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + 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..2b38ec62e 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/plugin/skills/impeccable/scripts/live-inject.mjs b/plugin/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/plugin/skills/impeccable/scripts/live-inject.mjs +++ b/plugin/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/plugin/skills/impeccable/scripts/live.mjs b/plugin/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/plugin/skills/impeccable/scripts/live.mjs +++ b/plugin/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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 || ''); diff --git a/plugin/skills/impeccable/scripts/live/project-ignores.mjs b/plugin/skills/impeccable/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/plugin/skills/impeccable/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/plugin/skills/impeccable/scripts/serve-question.mjs b/plugin/skills/impeccable/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/plugin/skills/impeccable/scripts/serve-question.mjs +++ b/plugin/skills/impeccable/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index bc06cdf7a..39764af77 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -12,32 +12,20 @@ const localNoChanges = !eventName && !process.env.CI_CHANGED_FILES; // (skill-behavior, accept-cleanup, deepseek), every single night. const isSchedule = eventName === 'schedule'; const changedFiles = localNoChanges || isSchedule ? [] : getChangedFiles(); -const forceDeterministic = localNoChanges || eventName === 'push' || eventName === 'workflow_dispatch'; +const forceDeterministic = localNoChanges || isSchedule || eventName === 'push' || eventName === 'workflow_dispatch'; const forceOptIn = eventName === 'workflow_dispatch'; -const plan = isSchedule - ? { - core: true, - detector: true, - live: true, - framework: true, - cli_remote_e2e: false, - live_e2e: true, - live_e2e_accept_cleanup: false, - skill_behavior: false, - live_svelte_adapter_deepseek: false, - } - : { - core: true, - detector: forceDeterministic || matchesSuiteTriggers('detector', changedFiles), - live: forceDeterministic || matchesSuiteTriggers('live', changedFiles), - framework: forceDeterministic || matchesSuiteTriggers('framework', changedFiles), - cli_remote_e2e: forceOptIn, - live_e2e: forceOptIn || matchesSuiteTriggers('live-e2e', changedFiles), - live_e2e_accept_cleanup: forceOptIn || matchesSuiteTriggers('live-e2e-accept-cleanup', changedFiles), - skill_behavior: forceOptIn || matchesSuiteTriggers('skill-behavior', changedFiles), - live_svelte_adapter_deepseek: forceOptIn || matchesSuiteTriggers('live-svelte-adapter-deepseek', changedFiles), - }; +const plan = { + core: true, + detector: forceDeterministic || matchesSuiteTriggers('detector', changedFiles), + live: forceDeterministic || matchesSuiteTriggers('live', changedFiles), + framework: forceDeterministic || matchesSuiteTriggers('framework', changedFiles), + cli_remote_e2e: forceOptIn, + live_e2e: isSchedule || forceOptIn || matchesSuiteTriggers('live-e2e', changedFiles), + live_e2e_accept_cleanup: forceOptIn || matchesSuiteTriggers('live-e2e-accept-cleanup', changedFiles), + skill_behavior: forceOptIn || matchesSuiteTriggers('skill-behavior', changedFiles), + live_svelte_adapter_deepseek: forceOptIn || matchesSuiteTriggers('live-svelte-adapter-deepseek', changedFiles), +}; writeGithubOutputs(plan); printSummary(plan, changedFiles); diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index ce2bbcf85..af5463804 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -135,6 +135,7 @@ export const SUITES = { 'tests/live-accept-css.test.mjs', 'tests/live-accept-scrub.test.mjs', 'tests/live-browser-dom.test.mjs', + 'tests/live-browser-ignores.test.mjs', 'tests/live-browser-script-parts.test.mjs', 'tests/live-browser-regression.test.mjs', 'tests/live-browser-session.test.mjs', @@ -156,6 +157,7 @@ export const SUITES = { 'tests/live-insert-ui.test.mjs', 'tests/live-manual-edits-buffer.test.mjs', 'tests/live-poll.test.mjs', + 'tests/live-project-ignores.test.mjs', 'tests/live-poll-lanes.test.mjs', 'tests/live-poll-stream.test.mjs', 'tests/live-recovery-commands.test.mjs', diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/skill/scripts/lib/live-path-globs.mjs b/skill/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/skill/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/skill/scripts/live-browser-ignores.js b/skill/scripts/live-browser-ignores.js new file mode 100644 index 000000000..1c8514c7b --- /dev/null +++ b/skill/scripts/live-browser-ignores.js @@ -0,0 +1,242 @@ +/** + * 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. + * 4. `ignoreFiles` globs that name the page waive it wholesale: the + * resolver reports `skipScan: true` and the detector answers the scan + * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI + * and the edit hook's own ignoreFiles gate. + * + * `pageFiles`, when the server could resolve it, lists the real project + * files the inject config serves. A URL that suffix-matches exactly one of + * them takes that file as its only project identity; an ambiguous or absent + * match falls back to the served-root common ancestor below. + * + * Known gap, unchanged from PR #645: framework apps inject into source files + * (src/routes/about/+page.svelte) while scans see route URLs (/about), so + * entries scoped to source or asset paths never match a page candidate and + * are dropped. That shows the finding, which is the conservative direction. + * + * 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, pageFiles) { + 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 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); + + // The served page list names the real files the inject config serves. + // A URL that suffix-matches exactly one of them has an unambiguous + // project identity; assert that identity and stop guessing from roots + // (PR #645 review: with src/ and public/ both served, /foo.html must not + // borrow src/foo.html's waivers while actually serving public/foo.html). + // Zero matches or several fall through to the common-ancestor fallback: + // ambiguity resolves toward showing the finding. + const knownPages = []; + for (const entry of Array.isArray(pageFiles) ? pageFiles : []) { + if (typeof entry !== 'string' || !entry) continue; + if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry); + } + if (knownPages.length === 1) { + addSuffixes(knownPages[0]); + return [...candidates]; + } + + 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); + } + + 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}>, skipScan: boolean }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots, config.pageFiles); + + // detector.ignoreFiles waives whole files. When any glob names this + // page, the scan itself is skipped; rule and value lists are returned + // empty because nothing will run. + const ignoreFileGlobs = asArray(config.ignoreFiles) + .filter((glob) => typeof glob === 'string' && glob.trim()); + if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) { + return { disabledRules: [], disabledValues: [], skipScan: true }; + } + + 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, skipScan: false }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 69d227b0a..2b38ec62e 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -11143,10 +11143,36 @@ 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, ignoreFiles that name the page skip the + // scan wholesale, and the rest match on the finding's own value inside + // the detector. Guarded twice: a stale cached live.js without the + // resolver part still scans, and a resolver that throws must not brick + // the detect toggle; both degrade to an unfiltered scan. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + let ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + if (typeof ignoresApi?.resolveDetectIgnores === 'function') { + try { + ignores = ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) || ignores; + } catch (e) { + ignores = { disabledRules: [], disabledValues: [], skipScan: false }; + } + } window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/skill/scripts/live-inject.mjs b/skill/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/skill/scripts/live-inject.mjs +++ b/skill/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index dafa8bd0c..ebdb8f4d8 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -48,6 +48,7 @@ import { writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; +import { collectProjectDetectorIgnores } from './live/project-ignores.mjs'; import { createManualApplyController, summarizeManualApplyFailures, @@ -754,6 +755,17 @@ 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. Config comes + // from every root the session spans (appRoot, contextRoot, repoRoot): + // in a monorepo the hook and the CLI key it at the repo root, which + // is not the appRoot this process chdir'd onto. + projectIgnores: collectProjectDetectorIgnores({ + appRoot: process.cwd(), + contextRoot: LIVE_ROOTS?.contextRoot, + repoRoot: LIVE_ROOTS?.repoRoot, + scriptsDir: __dirname, + }), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/skill/scripts/live.mjs b/skill/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/skill/scripts/live.mjs +++ b/skill/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/skill/scripts/live/browser-script-parts.mjs b/skill/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/skill/scripts/live/browser-script-parts.mjs +++ b/skill/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/skill/scripts/live/project-ignores.mjs b/skill/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/skill/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // 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 = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/skill/scripts/serve-question.mjs b/skill/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/skill/scripts/serve-question.mjs +++ b/skill/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } diff --git a/tests/ci-test-plan.test.mjs b/tests/ci-test-plan.test.mjs index d8b33fe72..2a9c16124 100644 --- a/tests/ci-test-plan.test.mjs +++ b/tests/ci-test-plan.test.mjs @@ -103,7 +103,9 @@ describe('ci-test-plan', () => { assert.equal(outputs.live_svelte_adapter_deepseek, 'false'); assert.equal(outputs.cli_remote_e2e, 'false'); assert.equal(outputs.core, 'true'); + assert.equal(outputs.detector, 'true'); assert.equal(outputs.live, 'true'); + assert.equal(outputs.framework, 'true'); }); }); diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index e9af1e5c9..0a264bf28 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -981,6 +981,233 @@ describe('detectUrl — browser-only fixtures', () => { } }); + it('extension mode suppresses disabledValues entries from scan config', async () => { + // The live overlay resolves .impeccable ignoreValues per page and sends + // the survivors as config.disabledValues (issue #639); the detector must + // filter them where the findings are assembled, since the overlay draws + // its own markers from the collected findings. + const normalized = normalizeDesignSystem({ + frontmatter: { + typography: { + display: { fontFamily: 'Avenir Next, Georgia, serif' }, + body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif' }, + }, + colors: { + ink: '#241f1a', + paper: '#f7f4ee', + surface: '#ffffff', + accent: '#b8422e', + border: '#d4c7b9', + }, + rounded: { + sm: '4px', + md: '8px', + '"2xl"': '32px', + full: '999px', + }, + }, + }); + // The JSON-safe payload shape the extension panel and detectUrl inject as + // __IMPECCABLE_CONFIG__.designSystem (serializeDesignSystemForBrowser in + // cli/engine/engines/browser/detect-url.mjs). + const designSystem = { + present: true, + hasFonts: normalized.hasFonts === true, + allowedFonts: Array.from(normalized.allowedFonts || []), + hasColors: normalized.hasColors === true, + allowedColors: Array.from(normalized.allowedColorKeys?.values?.() || []) + .map(entry => entry?.color) + .filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) + .map(color => ({ r: color.r, g: color.g, b: color.b })), + hasRadii: normalized.hasRadii === true, + allowedRadii: (normalized.allowedRadii || []) + .map(entry => Number(entry?.px)) + .filter(px => Number.isFinite(px)), + hasPillRadius: normalized.hasPillRadius === true, + }; + const puppeteer = await import('puppeteer'); + const browser = await puppeteer.default.launch({ + headless: true, + args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [], + }); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + await page.goto(`${baseUrl}/fixtures/antipatterns/design-system.html`, { waitUntil: 'load' }); + const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8'); + await page.evaluate(() => { + document.documentElement.dataset.impeccableExtension = 'true'; + window.__impeccableMessages = []; + window.addEventListener('message', event => { + if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return; + window.__impeccableMessages.push(event.data); + }); + }); + await page.evaluate(browserScript); + const scan = (scanId, disabledValues, extraConfig = {}) => page.evaluate(async (config) => { + window.postMessage({ source: 'impeccable-command', action: 'scan', config }, '*'); + const deadline = Date.now() + 2000; + while ( + Date.now() < deadline && + !window.__impeccableMessages.some(message => + message.source === 'impeccable-results' && message.scanId === config.scanId) + ) { + await new Promise(resolve => setTimeout(resolve, 25)); + } + const resultMessage = window.__impeccableMessages.find(message => + message.source === 'impeccable-results' && message.scanId === config.scanId); + const flat = (resultMessage?.findings || []).flatMap(group => group.findings || []); + return { + total: flat.length, + colors: flat.filter(finding => finding.type === 'design-system-color').length, + colorValues: flat + .filter(finding => finding.type === 'design-system-color') + .map(finding => finding.ignoreValue || ''), + fonts: flat + .filter(finding => finding.type === 'design-system-font') + .map(finding => finding.ignoreValue || ''), + }; + }, { scanId, visualContrast: false, designSystem, ...(disabledValues ? { disabledValues } : {}), ...extraConfig }); + + const unfiltered = await scan('scan-dv-1'); + assert.ok( + unfiltered.fonts.some(value => /poppins/i.test(value)), + `expected an undocumented poppins font finding, got: ${JSON.stringify(unfiltered)}`, + ); + + const filtered = await scan('scan-dv-2', [{ rule: 'design-system-font', value: 'poppins' }]); + assert.equal( + filtered.fonts.some(value => /poppins/i.test(value)), + false, + `expected the poppins waiver to suppress its finding, got: ${JSON.stringify(filtered)}`, + ); + const waivedCount = unfiltered.fonts.filter(value => /poppins/i.test(value)).length; + assert.equal( + filtered.total, + unfiltered.total - waivedCount, + `expected exactly the waived findings to disappear, got: ${JSON.stringify({ unfiltered, filtered })}`, + ); + assert.equal( + filtered.colors, + unfiltered.colors, + `expected unrelated design-system findings to survive, got: ${JSON.stringify({ unfiltered, filtered })}`, + ); + + // Color waivers match by value, not by spelling: the browser reports + // computed rgb(...) strings, the waiver is written as hex (mirrors + // ignoreValueMatches -> colorIgnoreKey in cli/lib/impeccable-config.mjs). + const rgbToHex = (value) => { + const m = String(value).match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/i); + if (!m) return null; + return `#${[m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0')).join('')}`; + }; + const rgbColor = unfiltered.colorValues.find(value => rgbToHex(value)); + assert.ok( + rgbColor, + `expected an rgb()-reported design-system-color finding, got: ${JSON.stringify(unfiltered.colorValues)}`, + ); + const hexWaiver = rgbToHex(rgbColor); + const colorFiltered = await scan('scan-dv-3', [{ rule: 'design-system-color', value: hexWaiver }]); + const waivedColorCount = unfiltered.colorValues.filter(value => value === rgbColor).length; + assert.equal( + colorFiltered.colors, + unfiltered.colors - waivedColorCount, + `expected the hex waiver ${hexWaiver} to suppress the ${rgbColor} findings, got: ${JSON.stringify({ colorValues: unfiltered.colorValues, colorFiltered })}`, + ); + assert.equal( + colorFiltered.fonts.some(value => /poppins/i.test(value)), + true, + `expected unrelated font findings to survive the color waiver, got: ${JSON.stringify(colorFiltered)}`, + ); + + // A page waived wholesale by detector.ignoreFiles arrives with + // config.skipScan and must scan to nothing at all. + const skipped = await scan('scan-dv-4', null, { skipScan: true }); + assert.equal(skipped.total, 0, `expected skipScan to empty the scan, got: ${JSON.stringify(skipped)}`); + await page.close(); + } finally { + await browser.close().catch(() => {}); + } + }); + + it('extension scan: skipScan suppresses the visual contrast stage too', async () => { + const puppeteer = await import('puppeteer'); + const browser = await puppeteer.default.launch({ + headless: true, + args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [], + }); + try { + const page = await browser.newPage(); + // Keep failing visual-contrast cards inside the no-scroll viewport. + await page.setViewport({ width: 1280, height: 1000 }); + await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' }); + const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8'); + await page.evaluate(() => { + document.documentElement.dataset.impeccableExtension = 'true'; + window.__impeccableMessages = []; + window.addEventListener('message', event => { + if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return; + window.__impeccableMessages.push(event.data); + }); + }); + await page.evaluate(browserScript); + const resultsFor = (scanId) => page.evaluate((id) => ( + (window.__impeccableMessages || []) + .filter(m => m.source === 'impeccable-results' && m.scanId === id) + .map(m => ({ + count: m.count, + types: (m.findings || []).flatMap(g => (g.findings || []).map(f => f.type || f.id)), + })) + ), scanId); + + // Control: the visual pass runs after the analytic scan and re-posts + // results carrying its low-contrast findings. This is exactly what an + // ignoreFiles-waived page must not do. + await page.evaluate(() => { + window.postMessage({ + source: 'impeccable-command', + action: 'scan', + config: { scanId: 'vc-skip-1', visualContrast: true, visualContrastMaxCandidates: 20 }, + }, '*'); + }); + const controlDeadline = Date.now() + 8000; + let control = []; + while (Date.now() < controlDeadline) { + control = await resultsFor('vc-skip-1'); + if (control.some(r => r.types.includes('low-contrast'))) break; + await new Promise(resolve => setTimeout(resolve, 100)); + } + assert.ok( + control.some(r => r.types.includes('low-contrast')), + `expected the control scan's visual pass to report low-contrast, got: ${JSON.stringify(control)}`, + ); + + // skipScan: a page waived wholesale by detector.ignoreFiles must stay + // at zero through the async visual stage as well: no results post with + // findings, no markers. + await page.evaluate(() => { + window.postMessage({ + source: 'impeccable-command', + action: 'scan', + config: { scanId: 'vc-skip-2', visualContrast: true, visualContrastMaxCandidates: 20, skipScan: true }, + }, '*'); + }); + await new Promise(resolve => setTimeout(resolve, 2500)); + const skipped = await resultsFor('vc-skip-2'); + assert.ok(skipped.length >= 1, `expected the skipScan scan to post results, got: ${JSON.stringify(skipped)}`); + assert.ok( + skipped.every(r => r.count === 0 && r.types.length === 0), + `expected every skipScan results post to stay empty, got: ${JSON.stringify(skipped)}`, + ); + const overlays = await page.evaluate(() => + document.querySelectorAll('.impeccable-overlay, .impeccable-label').length); + assert.equal(overlays, 0, `expected no markers on a skipScan page, got ${overlays}`); + await page.close(); + } finally { + await browser.close().catch(() => {}); + } + }); + it('browser API: impeccableDetect is pure, impeccableScan decorates', async () => { const puppeteer = await import('puppeteer'); const browser = await puppeteer.default.launch({ diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index e11d3fb62..4719573eb 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -9,6 +9,7 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; @@ -24,6 +25,8 @@ import { truthy, getConfigPath, getLocalConfigPath, + getCachePath, + getPendingPath, ensureHookGitExcludes, readConfig, readCache, @@ -67,6 +70,12 @@ import { import { normalizeIgnoreValueEntries as normalizeIgnoreValueEntriesCli } from '../cli/lib/impeccable-config.mjs'; import { detectHtml, detectText } from '../cli/engine/detect-antipatterns.mjs'; +// Hook state paths are env-sensitive: an ambient IMPECCABLE_CACHE_ROOT (a +// developer using the redirect locally) would relocate cache/pending out of +// the tmp projects and break stock-path assertions. Clear it up front; the +// dedicated issue-#422 suite sets and restores it explicitly. +delete process.env.IMPECCABLE_CACHE_ROOT; + function mkTmp() { return fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-hook-')); } @@ -415,6 +424,195 @@ describe('readCache / persistCache / bumpEditCount', () => { }); }); +describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => { + let cwd; + let cacheRoot; + let savedEnv; + beforeEach(() => { + cwd = mkTmp(); + cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cache-root-')); + savedEnv = process.env.IMPECCABLE_CACHE_ROOT; + }); + afterEach(() => { + if (savedEnv === undefined) delete process.env.IMPECCABLE_CACHE_ROOT; + else process.env.IMPECCABLE_CACHE_ROOT = savedEnv; + fs.rmSync(cwd, { recursive: true, force: true }); + fs.rmSync(cacheRoot, { recursive: true, force: true }); + }); + + it('keeps hook state project-local when the env var is unset', () => { + delete process.env.IMPECCABLE_CACHE_ROOT; + assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json')); + assert.equal(getPendingPath(cwd), path.join(cwd, '.impeccable', 'hook.pending.json')); + }); + + it('treats a blank env var as unset', () => { + process.env.IMPECCABLE_CACHE_ROOT = ' '; + assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json')); + }); + + // Mirrors hookStateDir's slug formula: readable separator-mapped path plus + // an 8-hex sha256 disambiguator. + function slugFor(p) { + const resolved = path.resolve(p); + const readable = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return `${readable}-${digest}`; + } + + it('relocates cache and pending under a per-project slug dir', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + assert.equal(getCachePath(cwd), path.join(cacheRoot, slugFor(cwd), 'hook.cache.json')); + assert.equal(getPendingPath(cwd), path.join(cacheRoot, slugFor(cwd), 'hook.pending.json')); + }); + + it('slug maps separators, colons, and dots to hyphens, with a digest suffix', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const proj = path.join(cwd, 'my.app', 'v2'); + const slugDir = path.basename(path.dirname(getCachePath(proj))); + assert.doesNotMatch(slugDir, /[:\\/.]/, 'no path-significant chars survive'); + assert.match(slugDir, /my-app-v2-[0-9a-f]{8}$/, `readable slug + 8-hex digest (got ${slugDir})`); + }); + + it('distinct projects whose readable slugs collide get distinct state dirs', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const dotted = path.join(cwd, 'my.app'); + const dashed = path.join(cwd, 'my-app'); + // Readable part is identical for both... + assert.equal( + path.resolve(dotted).replace(/[:\\/.]/g, '-'), + path.resolve(dashed).replace(/[:\\/.]/g, '-'), + ); + // ...but the digest keeps their hook state apart. + assert.notEqual(path.dirname(getCachePath(dotted)), path.dirname(getCachePath(dashed))); + }); + + it('trailing separators and relative segments slug to the same dir', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const canonical = getCachePath(cwd); + assert.equal(getCachePath(cwd + path.sep), canonical); + assert.equal(getCachePath(path.join(cwd, 'sub', '..')), canonical); + }); + + it('trims stray whitespace from the env value', () => { + process.env.IMPECCABLE_CACHE_ROOT = ` ${cacheRoot} `; + assert.equal(getCachePath(cwd), path.join(cacheRoot, slugFor(cwd), 'hook.cache.json')); + }); + + it('persistCache degrades gracefully when the cache root is unusable', () => { + // Point the root at an existing FILE so mkdir of the slug dir must fail. + const blocker = path.join(cacheRoot, 'not-a-dir'); + fs.writeFileSync(blocker, 'x'); + process.env.IMPECCABLE_CACHE_ROOT = blocker; + const cache = readCache(cwd); + bumpEditCount(cache, 'sid-1', '/x/a.tsx'); + assert.equal(persistCache(cwd, cache), false, 'returns false instead of throwing'); + assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false); + }); + + it('config paths stay project-local even when the redirect is active', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + assert.equal(getConfigPath(cwd), path.join(cwd, '.impeccable', 'config.json')); + assert.equal(getLocalConfigPath(cwd), path.join(cwd, '.impeccable', 'config.local.json')); + }); + + it('expands a leading ~/ against os.homedir()', () => { + // Property check without duplicating the expansion: the tilde form must + // resolve identically to the explicit homedir-joined form. + process.env.IMPECCABLE_CACHE_ROOT = path.join(os.homedir(), 'impeccable-state'); + const explicit = getCachePath(cwd); + process.env.IMPECCABLE_CACHE_ROOT = '~/impeccable-state'; + assert.equal(getCachePath(cwd), explicit); + assert.ok(explicit.startsWith(os.homedir()), 'anchored under the home dir'); + }); + + it('persistCache round-trips through the redirect dir and leaves the project root clean', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const cache = readCache(cwd); + bumpEditCount(cache, 'sid-1', '/x/a.tsx'); + assert.equal(persistCache(cwd, cache), true); + + assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'project root untouched'); + assert.equal(fs.existsSync(path.join(cacheRoot, slugFor(cwd), 'hook.cache.json')), true); + + const reloaded = readCache(cwd); + assert.equal(reloaded.sessions['sid-1'].files['/x/a.tsx'].editCount, 1); + }); + + function redirectEventFor(file, sessionId = 'redir-sid') { + return { + session_id: sessionId, + cwd, + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: file }, + }; + } + + function writeProjectFile(rel, body) { + const abs = path.join(cwd, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body); + return abs; + } + + it('runHook end-to-end: findings persist under the redirect root, project root stays clean', async () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const file = writeProjectFile('src/Card.tsx', 'noop'); + const det = fakeDetector([finding('text-overflow', 1)]); + + const first = await runHook({ + stdinJson: JSON.stringify(redirectEventFor(file)), + env: {}, cwd, detector: det, + }); + assert.match(first.stdout, /Design hook findings requiring review/); + assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'no project-local footprint'); + assert.equal(fs.existsSync(getCachePath(cwd)), true, 'cache lands under the redirect root'); + + // Session dedup still works across runs through the redirected cache. + const second = await runHook({ + stdinJson: JSON.stringify(redirectEventFor(file)), + env: {}, cwd, detector: det, + }); + assert.doesNotMatch(second.stdout, /Design hook findings requiring review/); + assert.match(second.stdout, /flagged earlier this session/); + }); + + it('runHook end-to-end: clean edits keep persisting editCount once redirected state exists', async () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const file = writeProjectFile('src/Card.tsx', 'noop'); + + // Earn the footprint (in the redirect dir) with a real finding first. + await runHook({ + stdinJson: JSON.stringify(redirectEventFor(file)), + env: {}, cwd, detector: fakeDetector([finding('text-overflow', 1)]), + }); + assert.equal(fs.existsSync(getCachePath(cwd)), true); + + // A clean follow-up edit must still persist its editCount bump — the + // opted-in check has to see the redirected cache, not just `/.impeccable/`. + await runHook({ + stdinJson: JSON.stringify(redirectEventFor(file)), + env: {}, cwd, detector: fakeDetector([]), + }); + const cache = readCache(cwd); + assert.equal(cache.sessions['redir-sid'].files[file].editCount, 2); + assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'project root still clean'); + }); + + it('runHook end-to-end: a no-footprint clean edit writes nothing anywhere (gates hold under redirect)', async () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const file = writeProjectFile('src/Card.tsx', 'noop'); + const r = await runHook({ + stdinJson: JSON.stringify(redirectEventFor(file)), + env: {}, cwd, detector: fakeDetector([]), + }); + assert.match(r.stdout, /No deterministic design-quality issues found/); + assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false); + assert.equal(fs.existsSync(getCachePath(cwd)), false, 'redirect root also stays empty'); + }); +}); + describe('ensureHookGitExcludes()', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); diff --git a/tests/live-browser-ignores.test.mjs b/tests/live-browser-ignores.test.mjs new file mode 100644 index 000000000..46745fa3e --- /dev/null +++ b/tests/live-browser-ignores.test.mjs @@ -0,0 +1,336 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SCRIPT = join(REPO_ROOT, 'skill/scripts/live-browser-ignores.js'); + +// Evaluated in the test realm (not a vm context) so the arrays the resolver +// returns share this realm's prototypes and deepEqual compares them plainly. +function loadIgnoresApi() { + const source = readFileSync(SCRIPT, 'utf-8'); + const factory = vm.runInThisContext( + `(function (window) {\n${source}\nreturn window.__IMPECCABLE_LIVE_IGNORES__;\n})`, + { filename: SCRIPT }, + ); + return factory({}); +} + +const resolve = loadIgnoresApi().resolveDetectIgnores; + +const EMPTY = { disabledRules: [], disabledValues: [], skipScan: false }; + +describe('live-browser-ignores resolver', () => { + it('registers a versioned API on the root', () => { + const api = loadIgnoresApi(); + assert.equal(api.version, 1); + assert.equal(typeof api.resolveDetectIgnores, 'function'); + }); + + it('degrades to an empty filter when the config global is missing or malformed', () => { + assert.deepEqual(resolve(), EMPTY); + assert.deepEqual(resolve({ ignores: undefined, pathname: '/index.html' }), EMPTY); + assert.deepEqual(resolve({ ignores: null, pathname: '/index.html' }), EMPTY); + assert.deepEqual(resolve({ ignores: 'nonsense', pathname: '/index.html' }), EMPTY); + assert.deepEqual(resolve({ ignores: {}, pathname: '/index.html' }), EMPTY); + }); + + it('does not spread a string ignoreRules into characters', () => { + // `"ignoreRules": "foo"` in a hand-edited config must disable nothing, + // not look like it disabled three one-letter rules. + const out = resolve({ ignores: { ignoreRules: 'foo' }, pathname: '/index.html' }); + assert.deepEqual(out, EMPTY); + }); + + it('forwards ignoreRules normalized and deduplicated', () => { + const out = resolve({ + ignores: { ignoreRules: ['Dark-Glow', 'dark-glow', ' gradient-text ', '', 42, null] }, + pathname: '/index.html', + }); + assert.deepEqual(out.disabledRules, ['dark-glow', 'gradient-text']); + }); + + it('forwards unscoped value entries and drops malformed ones', () => { + const out = resolve({ + ignores: { + ignoreValues: [ + { rule: 'overused-font', value: 'Geist+Mono' }, + null, + 'not-an-entry', + { rule: '', value: 'x' }, + { rule: 'overused-font', value: '' }, + ], + }, + pathname: '/index.html', + }); + assert.deepEqual(out.disabledValues, [{ rule: 'overused-font', value: 'geist mono' }]); + }); + + it('applies wildcard entries only on pages their globs name', () => { + const ignores = { + roots: ['prototype/'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['prototype/attack-the-soc.html'] }, + ], + }; + const onPage = resolve({ ignores, pathname: '/attack-the-soc.html' }); + assert.deepEqual(onPage.disabledRules, ['dark-glow']); + const elsewhere = resolve({ ignores, pathname: '/index.html' }); + assert.deepEqual(elsewhere.disabledRules, []); + }); + + it('never applies an unscoped wildcard entry, matching the CLI', () => { + // isIgnoredFindingValue in cli/lib/impeccable-config.mjs returns false + // for a wildcard entry with no files; project-wide suppression is + // ignoreRules' job. + const out = resolve({ + ignores: { ignoreValues: [{ rule: 'dark-glow', value: '*' }] }, + pathname: '/index.html', + }); + assert.deepEqual(out, EMPTY); + }); + + it('drops scoped value entries on pages outside their globs', () => { + const ignores = { + roots: ['prototype/'], + ignoreValues: [ + { rule: 'overused-font', value: 'geist mono', files: ['prototype/mgmt-demo.html'] }, + ], + }; + const onPage = resolve({ ignores, pathname: '/mgmt-demo.html' }); + assert.deepEqual(onPage.disabledValues, [{ rule: 'overused-font', value: 'geist mono' }]); + const elsewhere = resolve({ ignores, pathname: '/index.html' }); + assert.deepEqual(elsewhere.disabledValues, []); + }); + + it('does not lend one entry\'s glob prefix to other pages', () => { + // The trap from issue #639: prefixes come only from `roots`, never from + // the ignore globs themselves. An entry scoped to prototype/library/** + // must not suppress on prototype/index.html. + const ignores = { + roots: ['prototype/'], + ignoreValues: [ + { rule: 'em-dash-overuse', value: '*', files: ['prototype/library/**'] }, + ], + }; + const inside = resolve({ ignores, pathname: '/library/buttons.html' }); + assert.deepEqual(inside.disabledRules, ['em-dash-overuse']); + const outside = resolve({ ignores, pathname: '/index.html' }); + assert.deepEqual(outside.disabledRules, []); + }); + + it('resolves root and directory URLs to their index file', () => { + const ignores = { + roots: ['prototype/'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['prototype/index.html'] }, + { rule: 'gradient-text', value: '*', files: ['prototype/news/index.html'] }, + ], + }; + assert.deepEqual(resolve({ ignores, pathname: '/' }).disabledRules, ['dark-glow']); + assert.deepEqual(resolve({ ignores, pathname: '/news/' }).disabledRules, ['gradient-text']); + }); + + it('matches path suffixes like the CLI scoped-file matcher', () => { + // findingMatchesScopedIgnoreFile tries every path suffix of the finding's + // file, so `library/**` written without the prototype/ prefix still + // scopes to the library pages. + const ignores = { + roots: ['prototype/'], + ignoreValues: [ + { rule: 'em-dash-overuse', value: '*', files: ['library/**'] }, + { rule: 'dark-glow', value: '*', files: ['buttons.html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/library/buttons.html' }); + assert.deepEqual(out.disabledRules.sort(), ['dark-glow', 'em-dash-overuse']); + }); + + it('supports the CLI glob dialect, including alternation', () => { + const ignores = { + roots: ['prototype/'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['prototype/{index,about}.html'] }, + { rule: 'gradient-text', value: '*', files: ['prototype/page-?.html'] }, + ], + }; + assert.deepEqual(resolve({ ignores, pathname: '/about.html' }).disabledRules, ['dark-glow']); + assert.deepEqual(resolve({ ignores, pathname: '/page-3.html' }).disabledRules, ['gradient-text']); + assert.deepEqual(resolve({ ignores, pathname: '/page-33.html' }).disabledRules, []); + }); + + it('treats glob metacharacters in filenames literally', () => { + const ignores = { + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['pricing (v2).html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/pricing (v2).html' }); + assert.deepEqual(out.disabledRules, ['dark-glow']); + const near = resolve({ ignores, pathname: '/pricing xv2y.html' }); + assert.deepEqual(near.disabledRules, []); + }); + + it('accepts a single `file` string alongside `files`', () => { + const out = resolve({ + ignores: { + ignoreValues: [{ rule: 'dark-glow', value: '*', file: 'index.html' }], + }, + pathname: '/index.html', + }); + assert.deepEqual(out.disabledRules, ['dark-glow']); + }); + + it('asserts no prefix when the configured roots share no common ancestor', () => { + // With src/**/*.html and public/**/*.html both configured, no single + // document root maps /foo.html to a unique project file, so no prefix + // is asserted. A waiver naming src/foo.html must not hide a finding on + // a page served from public/foo.html; ambiguity resolves to showing + // the finding. Bare-path spellings still apply whichever root serves it. + const ignores = { + roots: ['src/', 'public/'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['src/foo.html'] }, + { rule: 'clipped-overflow-container', value: '*', files: ['src/foo.html', 'public/foo.html'] }, + { rule: 'em-dash-overuse', value: '*', files: ['foo.html'] }, + { rule: 'gradient-text', value: '*', files: ['**/foo.html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/foo.html' }); + assert.deepEqual(out.disabledRules.sort(), ['em-dash-overuse', 'gradient-text']); + }); + + it('reduces nested roots to their common ancestor so normal waivers keep applying', () => { + // Globs at two depths in one tree (prototype/*.html plus + // prototype/library/**/*.html) derive the prefixes prototype/ and + // prototype/library/. Those are not alternative identities: one server + // serves both, so the document root sits at their common ancestor and + // a project-relative waiver like prototype/index.html must apply. + const ignores = { + roots: ['prototype/', 'prototype/library/'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['prototype/index.html'] }, + { rule: 'em-dash-overuse', value: '*', files: ['prototype/library/**'] }, + ], + }; + assert.deepEqual( + resolve({ ignores, pathname: '/index.html' }).disabledRules, + ['dark-glow'], + ); + assert.deepEqual( + resolve({ ignores, pathname: '/library/buttons.html' }).disabledRules, + ['em-dash-overuse'], + ); + }); + + it('resolves a URL to its one served file when pageFiles knows it', () => { + // PR #645 review discussion r3840011436: with src/ and public/ both + // served, /foo.html used to borrow identities from every root. The + // served page list disambiguates: this URL serves public/foo.html, so + // src-scoped waivers must not apply. + const ignores = { + roots: ['src/', 'public/'], + pageFiles: ['src/other.html', 'public/foo.html'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['src/foo.html'] }, + { rule: 'gradient-text', value: '*', files: ['public/foo.html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/foo.html' }); + assert.deepEqual(out.disabledRules, ['gradient-text']); + }); + + it('keeps ambiguity conservative when served files share the URL suffix', () => { + const ignores = { + roots: ['src/', 'public/'], + pageFiles: ['src/foo.html', 'public/foo.html'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['src/foo.html'] }, + { rule: 'gradient-text', value: '*', files: ['public/foo.html'] }, + { rule: 'em-dash-overuse', value: '*', files: ['foo.html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/foo.html' }); + // Neither root-scoped waiver can claim the page; the bare spelling + // still applies whichever root serves it. + assert.deepEqual(out.disabledRules, ['em-dash-overuse']); + }); + + it('falls back to the common ancestor when index files collide across depths', () => { + // /index.html suffix-matches both served index files; the ambiguity + // resolves through the common ancestor, which still yields the correct + // shallow identity and never the deep one. + const ignores = { + roots: ['prototype/', 'prototype/library/'], + pageFiles: ['prototype/index.html', 'prototype/library/index.html'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['prototype/index.html'] }, + { rule: 'gradient-text', value: '*', files: ['prototype/library/index.html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/index.html' }); + assert.deepEqual(out.disabledRules, ['dark-glow']); + }); + + it('skips the scan on pages named by ignoreFiles', () => { + const ignores = { + roots: ['prototype/'], + ignoreFiles: ['prototype/library/**'], + ignoreRules: ['dark-glow'], + }; + const waived = resolve({ ignores, pathname: '/library/buttons.html' }); + assert.deepEqual(waived, { disabledRules: [], disabledValues: [], skipScan: true }); + const scanned = resolve({ ignores, pathname: '/index.html' }); + assert.equal(scanned.skipScan, false); + assert.deepEqual(scanned.disabledRules, ['dark-glow']); + }); + + it('matches ignoreFiles by basename like the CLI glob matcher', () => { + const out = resolve({ + ignores: { ignoreFiles: ['buttons.html'], roots: ['prototype/'] }, + pathname: '/library/buttons.html', + }); + assert.equal(out.skipScan, true); + }); + + it('treats a malformed ignoreFiles value as no waiver at all', () => { + const out = resolve({ + ignores: { ignoreFiles: 'prototype/**', roots: [] }, + pathname: '/index.html', + }); + assert.equal(out.skipScan, false); + }); + + it('drops entries scoped to source paths that no route URL can match', () => { + // Framework apps inject into source files while scans see route URLs; a + // source-scoped entry must fail conservative (finding shown), never + // suppress by accident. Pinned so a refactor cannot flip the direction. + const ignores = { + roots: ['src/'], + pageFiles: ['src/routes/about/+page.svelte'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['src/routes/about/+page.svelte'] }, + ], + }; + const out = resolve({ ignores, pathname: '/about' }); + assert.deepEqual(out.disabledRules, []); + assert.equal(out.skipScan, false); + }); + + it('survives malformed roots and percent-escapes without throwing', () => { + const out = resolve({ + ignores: { + roots: 7, + pageFiles: 'not-a-list', + ignoreFiles: [null, 42], + ignoreRules: ['dark-glow'], + ignoreValues: [{ rule: 'gradient-text', value: '*', files: ['broken%.html'] }], + }, + pathname: '/broken%.html', + }); + assert.deepEqual(out.disabledRules.sort(), ['dark-glow', 'gradient-text']); + }); +}); diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index 6be47eea7..7796404e3 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -714,8 +714,18 @@ describe('live-browser.js regression guards', () => { ); assert.match( SOURCE, - /function requestDetectScan\(\)[\s\S]{0,240}?const scanId = String\(\+\+detectScanSeq\);[\s\S]{0,80}?activeDetectScanId = scanId;[\s\S]{0,160}?config: \{ scanId \}/, - 'Detect scans must send a fresh scan id to the detector', + /function requestDetectScan\(\)[\s\S]{0,240}?const scanId = String\(\+\+detectScanSeq\);[\s\S]{0,80}?activeDetectScanId = scanId;[\s\S]{0,2200}?config: \{\s*scanId,\s*disabledRules: ignores\.disabledRules \|\| \[\],\s*disabledValues: ignores\.disabledValues \|\| \[\],\s*skipScan: ignores\.skipScan === true,\s*\},/, + 'Detect scans must send a fresh scan id plus the resolved project waivers to the detector', + ); + assert.match( + SOURCE, + /let ignores = \{ disabledRules: \[\], disabledValues: \[\], skipScan: false \};\s*if \(typeof ignoresApi\?\.resolveDetectIgnores === 'function'\) \{\s*try \{/, + 'a cached live.js without the ignores resolver part must still scan, just unfiltered', + ); + assert.match( + SOURCE, + /\} catch \(e\) \{\s*ignores = \{ disabledRules: \[\], disabledValues: \[\], skipScan: false \};\s*\}/, + 'a throwing ignores resolver must degrade to an unfiltered scan, not break the detect toggle', ); assert.match( SOURCE, diff --git a/tests/live-browser-script-parts.test.mjs b/tests/live-browser-script-parts.test.mjs index b8db88631..9c1a7a9ab 100644 --- a/tests/live-browser-script-parts.test.mjs +++ b/tests/live-browser-script-parts.test.mjs @@ -12,13 +12,15 @@ describe('live browser script parts', () => { it('resolves the canonical browser script order', () => { const parts = resolveLiveBrowserScriptParts('/repo/skill/scripts'); - assert.deepEqual(parts.map((part) => part.name), ['session-state', 'dom-helpers', 'browser-ui']); + assert.deepEqual(parts.map((part) => part.name), ['session-state', 'dom-helpers', 'project-ignores', 'browser-ui']); assert.equal(parts[0].file, 'live-browser-session.js'); assert.equal(parts[1].file, 'live-browser-dom.js'); - assert.equal(parts[2].file, 'live-browser.js'); + assert.equal(parts[2].file, 'live-browser-ignores.js'); + assert.equal(parts[3].file, 'live-browser.js'); assert.equal(parts[0].path, path.join('/repo/skill/scripts', 'live-browser-session.js')); assert.equal(parts[1].path, path.join('/repo/skill/scripts', 'live-browser-dom.js')); - assert.equal(parts[2].path, path.join('/repo/skill/scripts', 'live-browser.js')); + assert.equal(parts[2].path, path.join('/repo/skill/scripts', 'live-browser-ignores.js')); + assert.equal(parts[3].path, path.join('/repo/skill/scripts', 'live-browser.js')); }); it('asserts missing script parts by name', () => { @@ -37,6 +39,7 @@ describe('live browser script parts', () => { assert.deepEqual(loaded.map((part) => part.source), [ 'source:live-browser-session.js', 'source:live-browser-dom.js', + 'source:live-browser-ignores.js', 'source:live-browser.js', ]); }); @@ -50,16 +53,20 @@ describe('live browser script parts', () => { parts: [ { name: 'session-state', file: 'live-browser-session.js', source: 'window.__SESSION_PART__ = true;' }, { name: 'dom-helpers', file: 'live-browser-dom.js', source: 'window.__DOM_PART__ = true;' }, + { name: 'project-ignores', file: 'live-browser-ignores.js', source: 'window.__IGNORES_PART__ = true;' }, { name: 'browser-ui', file: 'live-browser.js', source: 'window.__BROWSER_PART__ = true;' }, ], + projectIgnores: { ignoreRules: ['dark-glow'], ignoreValues: [], ignoreFiles: [], roots: ['prototype/'], pageFiles: ['prototype/index.html'] }, }); const tokenIndex = script.indexOf('window.__IMPECCABLE_TOKEN__'); const portIndex = script.indexOf('window.__IMPECCABLE_PORT__'); const commandPrefixIndex = script.indexOf('window.__IMPECCABLE_COMMAND_PREFIX__'); const vocabIndex = script.indexOf('window.__IMPECCABLE_VOCAB__'); + const projectIgnoresIndex = script.indexOf('window.__IMPECCABLE_PROJECT_IGNORES__'); const sessionIndex = script.indexOf('window.__SESSION_PART__'); const domIndex = script.indexOf('window.__DOM_PART__'); + const ignoresIndex = script.indexOf('window.__IGNORES_PART__'); const browserIndex = script.indexOf('window.__BROWSER_PART__'); assert.ok(tokenIndex !== -1); @@ -67,11 +74,26 @@ describe('live browser script parts', () => { assert.ok(portIndex < commandPrefixIndex); assert.ok(commandPrefixIndex < vocabIndex); assert.match(script, /window\.__IMPECCABLE_COMMAND_PREFIX__ = "\$"/); - assert.ok(vocabIndex < sessionIndex); + assert.ok(vocabIndex < projectIgnoresIndex); + assert.ok(projectIgnoresIndex < sessionIndex); assert.ok(sessionIndex < domIndex); - assert.ok(domIndex < browserIndex); + assert.ok(domIndex < ignoresIndex); + assert.ok(ignoresIndex < browserIndex); + assert.match(script, /window\.__IMPECCABLE_PROJECT_IGNORES__ = \{"ignoreRules":\["dark-glow"\],"ignoreValues":\[\],"ignoreFiles":\[\],"roots":\["prototype\/"\],"pageFiles":\["prototype\/index\.html"\]\};/); assert.match(script, /impeccable live script part: session-state \(live-browser-session\.js\)/); assert.match(script, /impeccable live script part: dom-helpers \(live-browser-dom\.js\)/); + assert.match(script, /impeccable live script part: project-ignores \(live-browser-ignores\.js\)/); assert.match(script, /impeccable live script part: browser-ui \(live-browser\.js\)/); }); + + it('serializes null project ignores when none are passed', () => { + const script = assembleLiveBrowserScript({ + token: 'token-a', + port: 8421, + vocabulary: [], + parts: [], + }); + + assert.match(script, /window\.__IMPECCABLE_PROJECT_IGNORES__ = null;/); + }); }); diff --git a/tests/live-inject.test.mjs b/tests/live-inject.test.mjs index c58ba69f7..16a5ee4e9 100644 --- a/tests/live-inject.test.mjs +++ b/tests/live-inject.test.mjs @@ -10,10 +10,38 @@ import { dirname, join, relative, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; +import { livePathGlobToRegex } from '../skill/scripts/lib/live-path-globs.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const INJECT = resolve(__dirname, '..', 'skill/scripts/live-inject.mjs'); +describe('live path globs', () => { + it('matches recursive segments, including zero segments', () => { + const anywhere = livePathGlobToRegex('**/index.html'); + assert.equal(anywhere.test('index.html'), true); + assert.equal(anywhere.test('public/index.html'), true); + assert.equal(anywhere.test('apps/web/public/index.html'), true); + + const underPublic = livePathGlobToRegex('public/**/*.html'); + assert.equal(underPublic.test('public/index.html'), true); + assert.equal(underPublic.test('public/docs/index.html'), true); + assert.equal(underPublic.test('src/index.html'), false); + }); + + it('keeps single-star and question-mark matches inside one segment', () => { + const pattern = livePathGlobToRegex('pages/*/item?.html'); + assert.equal(pattern.test('pages/docs/item1.html'), true); + assert.equal(pattern.test('pages/docs/deep/item1.html'), false); + assert.equal(pattern.test('pages/docs/item12.html'), false); + }); + + it('treats regular-expression punctuation as literal path text', () => { + const pattern = livePathGlobToRegex('pages/[draft]/item+.html'); + assert.equal(pattern.test('pages/[draft]/item+.html'), true); + assert.equal(pattern.test('pages/d/itemm.html'), false); + }); +}); + function runInject(cwd, configPath, args) { try { const out = execFileSync('node', [INJECT, ...args], { diff --git a/tests/live-project-ignores.test.mjs b/tests/live-project-ignores.test.mjs new file mode 100644 index 000000000..3669d63f2 --- /dev/null +++ b/tests/live-project-ignores.test.mjs @@ -0,0 +1,145 @@ +import { describe, it, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { collectProjectDetectorIgnores } from '../skill/scripts/live/project-ignores.mjs'; + +const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const SCRIPTS_DIR = path.join(REPO_ROOT, 'skill', 'scripts'); + +const tempDirs = []; +function makeTemp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-project-ignores-')); + tempDirs.push(dir); + return dir; +} +after(() => { + for (const dir of tempDirs) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } + } +}); + +function write(root, rel, content) { + const filePath = path.join(root, rel); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function writeDetectorConfig(root, detector) { + write(root, '.impeccable/config.json', JSON.stringify({ detector }, null, 2)); +} + +function writeLiveConfig(root, files) { + write(root, '.impeccable/live/config.json', JSON.stringify({ + files, + insertBefore: '', + commentSyntax: 'html', + }, null, 2)); +} + +describe('collectProjectDetectorIgnores', () => { + it('collects waivers, roots, and pageFiles from a single-root project', () => { + const app = makeTemp(); + write(app, 'package.json', '{"name":"single","private":true}\n'); + writeDetectorConfig(app, { + ignoreRules: ['ai-color-palette'], + ignoreFiles: ['prototype/legacy/**'], + ignoreValues: [ + { rule: 'gradient-text', value: '*', files: ['prototype/library/**'], reason: 'stays local' }, + ], + }); + writeLiveConfig(app, ['prototype/index.html', 'prototype/library/buttons.html']); + write(app, 'prototype/index.html', ''); + write(app, 'prototype/library/buttons.html', ''); + + const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR }); + assert.deepEqual(out.ignoreRules, ['ai-color-palette']); + assert.deepEqual(out.ignoreFiles, ['prototype/legacy/**']); + // createdAt/reason stay local; only rule/value/files ride to the browser. + assert.deepEqual(out.ignoreValues, [ + { rule: 'gradient-text', value: '*', files: ['prototype/library/**'] }, + ]); + assert.deepEqual(out.roots.sort(), ['prototype/', 'prototype/library/']); + assert.deepEqual(out.pageFiles.sort(), ['prototype/index.html', 'prototype/library/buttons.html']); + }); + + it('reads waivers keyed at the repo root, where the hook and the CLI put them', () => { + // The monorepo shape from the PR #645 review: the live server chdirs + // onto the child appRoot, while resolveCacheCwd keys the hook's config + // at the session cwd, which is the repo root. + const repo = makeTemp(); + const app = path.join(repo, 'site'); + fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); + write(app, 'package.json', '{"name":"site","private":true}\n'); + writeDetectorConfig(repo, { + ignoreRules: ['ai-color-palette'], + ignoreValues: [{ rule: 'overused-font', value: 'space grotesk' }], + }); + writeLiveConfig(app, ['prototype/index.html']); + write(app, 'prototype/index.html', ''); + + const out = collectProjectDetectorIgnores({ appRoot: app, repoRoot: repo, scriptsDir: SCRIPTS_DIR }); + assert.deepEqual(out.ignoreRules, ['ai-color-palette']); + assert.deepEqual(out.ignoreValues, [{ rule: 'overused-font', value: 'space grotesk' }]); + // Identities serialize repo-relative so waivers spelled from either root + // match through the resolver's suffix expansion. + assert.deepEqual(out.roots, ['site/prototype/']); + assert.deepEqual(out.pageFiles, ['site/prototype/index.html']); + }); + + it('unions configs across roots and dedupes identical value entries', () => { + const repo = makeTemp(); + const app = path.join(repo, 'site'); + fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); + write(app, 'package.json', '{"name":"site","private":true}\n'); + writeDetectorConfig(repo, { + ignoreRules: ['ai-color-palette'], + ignoreValues: [{ rule: 'overused-font', value: 'space grotesk' }], + }); + writeDetectorConfig(app, { + ignoreRules: ['gradient-text', 'ai-color-palette'], + ignoreValues: [{ rule: 'overused-font', value: 'space grotesk' }], + }); + writeLiveConfig(app, ['prototype/index.html']); + write(app, 'prototype/index.html', ''); + + const out = collectProjectDetectorIgnores({ appRoot: app, repoRoot: repo, scriptsDir: SCRIPTS_DIR }); + assert.deepEqual(out.ignoreRules.sort(), ['ai-color-palette', 'gradient-text']); + assert.deepEqual(out.ignoreValues, [{ rule: 'overused-font', value: 'space grotesk' }]); + }); + + it('expands glob file entries to existing files and drops missing literals', () => { + const app = makeTemp(); + write(app, 'package.json', '{"name":"globs","private":true}\n'); + writeLiveConfig(app, ['prototype/**/*.html', 'prototype/not-created-yet.html']); + write(app, 'prototype/index.html', ''); + write(app, 'prototype/library/buttons.html', ''); + + const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR }); + assert.deepEqual(out.pageFiles.sort(), ['prototype/index.html', 'prototype/library/buttons.html']); + assert.deepEqual(out.roots.sort(), ['prototype/']); + }); + + it('degrades to empty arrays when nothing is configured', () => { + const app = makeTemp(); + write(app, 'package.json', '{"name":"bare","private":true}\n'); + const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR }); + assert.deepEqual(out, { ignoreRules: [], ignoreValues: [], ignoreFiles: [], roots: [], pageFiles: [] }); + }); + + it('survives a malformed detector config without throwing', () => { + const app = makeTemp(); + write(app, 'package.json', '{"name":"broken","private":true}\n'); + write(app, '.impeccable/config.json', '{"detector":{"ignoreRules":"foo","ignoreValues":[null,7],"ignoreFiles":{}}}'); + writeLiveConfig(app, ['prototype/index.html']); + write(app, 'prototype/index.html', ''); + + const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR }); + assert.deepEqual(out.ignoreRules, []); + assert.deepEqual(out.ignoreValues, []); + assert.deepEqual(out.ignoreFiles, []); + assert.deepEqual(out.pageFiles, ['prototype/index.html']); + }); +});