diff --git a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs index ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.agents/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.agents/skills/impeccable/scripts/live-browser-ignores.js +++ b/.agents/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.claude/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.claude/skills/impeccable/scripts/live-browser-ignores.js +++ b/.claude/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.cursor/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.cursor/skills/impeccable/scripts/live-browser-ignores.js +++ b/.cursor/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.gemini/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.gemini/skills/impeccable/scripts/live-browser-ignores.js +++ b/.gemini/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.github/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.github/skills/impeccable/scripts/live-browser-ignores.js +++ b/.github/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.grok/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.grok/skills/impeccable/scripts/live-browser-ignores.js +++ b/.grok/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.grok/skills/impeccable/scripts/live-browser.js b/.grok/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.grok/skills/impeccable/scripts/live-browser.js +++ b/.grok/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.grok/skills/impeccable/scripts/live-server.mjs b/.grok/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.grok/skills/impeccable/scripts/live-server.mjs +++ b/.grok/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.hermes/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.hermes/skills/impeccable/scripts/live-browser-ignores.js +++ b/.hermes/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.hermes/skills/impeccable/scripts/live-browser.js b/.hermes/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.hermes/skills/impeccable/scripts/live-browser.js +++ b/.hermes/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.hermes/skills/impeccable/scripts/live-server.mjs b/.hermes/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.hermes/skills/impeccable/scripts/live-server.mjs +++ b/.hermes/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.kiro/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.kiro/skills/impeccable/scripts/live-browser-ignores.js +++ b/.kiro/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.opencode/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.opencode/skills/impeccable/scripts/live-browser-ignores.js +++ b/.opencode/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.pi/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.pi/skills/impeccable/scripts/live-browser-ignores.js +++ b/.pi/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.qoder/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.qoder/skills/impeccable/scripts/live-browser-ignores.js +++ b/.qoder/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.rovodev/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser-ignores.js +++ b/.rovodev/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.trae-cn/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser-ignores.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.trae/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.trae/skills/impeccable/scripts/live-browser-ignores.js +++ b/.trae/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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 ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/.vibe/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/.vibe/skills/impeccable/scripts/live-browser-ignores.js +++ b/.vibe/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/.vibe/skills/impeccable/scripts/live-browser.js b/.vibe/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/.vibe/skills/impeccable/scripts/live-browser.js +++ b/.vibe/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/.vibe/skills/impeccable/scripts/live-server.mjs b/.vibe/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/.vibe/skills/impeccable/scripts/live-server.mjs +++ b/.vibe/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs b/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs index ed91c8fb1..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); @@ -1702,15 +1714,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -1722,11 +1739,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -1955,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, @@ -2008,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 f40a768f7..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); @@ -8361,15 +8373,20 @@ if (IS_BROWSER) { ]); // The design-system checks set `ignoreValue` on their findings; the // detail fallbacks catch overused-font, whose value lives in its - // sentence. Two CLI matchers are not mirrored here: the motion - // extractor (a value-scoped bounce-easing waiver only matches when the - // finding carries ignoreValue directly) and the design-system-color - // color-equality fallback (a waiver written as rgb() will not match a - // finding reported as hex; store the reported form). + // 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); @@ -8381,11 +8398,56 @@ if (IS_BROWSER) { } 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); + 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)); @@ -8614,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, @@ -8667,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/live-browser-ignores.js b/plugin/skills/impeccable/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/plugin/skills/impeccable/scripts/live-browser-ignores.js +++ b/plugin/skills/impeccable/scripts/live-browser-ignores.js @@ -16,6 +16,20 @@ * 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 @@ -101,7 +115,7 @@ // 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) { + function pageCandidates(pathname, roots, pageFiles) { let pagePath = String(pathname || ''); try { pagePath = decodeURIComponent(pagePath); @@ -113,6 +127,32 @@ // 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; @@ -125,14 +165,6 @@ common = common.slice(0, i); } - const candidates = new Set(); - const addSuffixes = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - }; - addSuffixes(pagePath); if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); return [...candidates]; } @@ -158,12 +190,21 @@ * 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}> }} + * @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); + 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) @@ -191,7 +232,7 @@ disabledValues.push({ rule, value }); } - return { disabledRules: [...disabledRules], disabledValues }; + return { disabledRules: [...disabledRules], disabledValues, skipScan: false }; } root.__IMPECCABLE_LIVE_IGNORES__ = { diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -11147,20 +11147,32 @@ void main() { // filters the same findings the CLI and the edit hook do (issue #639). // live-browser-ignores.js resolves .impeccable config for this page: // ignoreRules suppress outright, wildcard ignoreValues suppress their - // rule in the files they name, and the rest match on the finding's own - // value inside the detector. Guarded so a stale cached live.js without - // the resolver part still scans, just unfiltered as before. + // 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__; - const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' - ? ignoresApi.resolveDetectIgnores({ - ignores: window.__IMPECCABLE_PROJECT_IGNORES__, - pathname: location.pathname, - }) - : { disabledRules: [], disabledValues: [] }; + 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, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, + config: { + scanId, + disabledRules: ignores.disabledRules || [], + disabledValues: ignores.disabledValues || [], + skipScan: ignores.skipScan === true, + }, }, '*'); } diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -22,7 +22,6 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; -import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -46,10 +45,10 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, - resolveLiveConfigPath, 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, @@ -696,51 +695,6 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- -// Project detector waivers for the browser overlay (issue #639). The CLI and -// the edit hook filter findings through .impeccable/config.json; the overlay -// scans in the browser, so the same config rides along in the /live.js -// prelude and live-browser-ignores.js applies it per page at scan time. -function readProjectDetectorIgnores() { - // readConfig merges config.json with the gitignored config.local.json and - // type-checks both, exactly as the edit hook reads the same pair. - const config = readHookConfig(process.cwd()); - return { - ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], - // Serve only what the browser matches on; createdAt/reason stay local. - ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ - rule: entry.rule, - value: entry.value, - ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), - })), - roots: readLiveServedRoots(), - }; -} - -// Where the served pages live inside the project. Ignore globs are -// project-relative and the browser only knows its URL path, so it needs the -// prefix; the inject config's own `files` globs are the authority on it. -// Deriving it from the ignore globs instead fails silently: one entry scoped -// to prototype/library/** would lend prototype/library/ as a candidate prefix -// to every page, and that rule would suppress site-wide. -function readLiveServedRoots() { - try { - const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); - const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const files = Array.isArray(live?.files) ? live.files : []; - return [...new Set(files - .filter((glob) => typeof glob === 'string' && glob) - .map((glob) => { - const wildcardAt = glob.search(/[*?{]/); - const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); - const cut = head.lastIndexOf('/'); - return cut > -1 ? head.slice(0, cut + 1) : ''; - }))]; - } catch { - // No readable inject config: the browser matches URL paths as-is. - return []; - } -} - function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -802,8 +756,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) { appRoot: process.cwd(), parts, // Read per request rather than cached, so editing the config and - // reloading the tab is enough to pick up a new waiver. - projectIgnores: readProjectDetectorIgnores(), + // 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/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; }