From 5330fa358e1eb6c067dca3c9c39323ba48dc5c1f Mon Sep 17 00:00:00 2001 From: Guitaraholic Date: Mon, 24 Aug 2026 00:57:13 +0100 Subject: [PATCH 01/16] Fix: honour .impeccable detector ignores in the live overlay (#639) The live overlay's detect scan ran unfiltered: requestDetectScan() posted only { scanId }, so detector.ignoreRules and detector.ignoreValues in .impeccable/config.json reached impeccable detect and the edit hook but never the surface a designer actually watches. The server now serializes the project's detector waivers into the /live.js prelude (window.__IMPECCABLE_PROJECT_IGNORES__), read per request through hook-lib's readConfig so config.local.json wins and edits land on the next tab reload. A new script part, live-browser-ignores.js, resolves that config against the page URL when a scan starts: ignoreRules suppress outright, wildcard ignoreValues suppress their rule in the files their globs name, and the remaining entries ride along as disabledValues for the detector to match on each finding's own value. The detector bundle applies those where the findings are assembled, since the overlay draws its own markers from the collected findings. Scope resolution mirrors cli/lib/impeccable-config.mjs deliberately: the same glob dialect (globToRegex, including {a,b} alternation), the same path-suffix matching as findingMatchesScopedIgnoreFile, and the same refusal to apply an unscoped wildcard entry. The served-root prefixes that bridge project-relative globs and site-relative URLs come from the inject config's own files globs, never from the ignore globs; deriving them from the ignore globs lets one entry scoped to prototype/library/** lend its prefix to every page and suppress site-wide, which looks like success because the numbers go down. Known gaps, recorded in the detector comment: the motion value extractor is not mirrored, so a value-scoped bounce-easing waiver only matches when the finding carries ignoreValue directly, and design-system-color matches on the normalized string without the CLI's color-equality fallback. Tests: unit tests for the resolver part (stale globals, string ignoreRules, malformed entries, directory URLs, percent-escapes, glob metacharacters, the roots trap), an extension-mode puppeteer test that disabledValues suppress exactly the waived findings, and the live-browser regression pin now asserts the new scan config shape instead of { scanId }. Co-Authored-By: Claude Fable 5 --- cli/engine/browser/injected/index.mjs | 63 +++++++ cli/engine/detect-antipatterns-browser.js | 63 +++++++ scripts/test-suites.mjs | 1 + skill/scripts/live-browser-ignores.js | 181 ++++++++++++++++++ skill/scripts/live-browser.js | 16 +- skill/scripts/live-server.mjs | 50 +++++ skill/scripts/live/browser-script-parts.mjs | 9 +- tests/detect-antipatterns-browser.test.mjs | 114 +++++++++++ tests/live-browser-ignores.test.mjs | 197 ++++++++++++++++++++ tests/live-browser-regression.test.mjs | 9 +- tests/live-browser-script-parts.test.mjs | 32 +++- 11 files changed, 726 insertions(+), 9 deletions(-) create mode 100644 skill/scripts/live-browser-ignores.js create mode 100644 tests/live-browser-ignores.test.mjs diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 1c1f64c58..5d2d1f227 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -131,6 +131,7 @@ export const SUITES = { 'tests/live-accept-css.test.mjs', 'tests/live-accept-scrub.test.mjs', 'tests/live-browser-dom.test.mjs', + 'tests/live-browser-ignores.test.mjs', 'tests/live-browser-script-parts.test.mjs', 'tests/live-browser-regression.test.mjs', 'tests/live-browser-session.test.mjs', diff --git a/skill/scripts/live-browser-ignores.js b/skill/scripts/live-browser-ignores.js new file mode 100644 index 000000000..fd78fe21b --- /dev/null +++ b/skill/scripts/live-browser-ignores.js @@ -0,0 +1,181 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = ['']; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry === '' || entry.endsWith('/') ? entry : entry + '/'); + } + + const candidates = new Set(); + for (const prefix of prefixes) { + const full = prefix + pagePath; + const parts = full.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + } + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/skill/scripts/live/browser-script-parts.mjs b/skill/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/skill/scripts/live/browser-script-parts.mjs +++ b/skill/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index e77d93231..ffffd3bd7 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -981,6 +981,120 @@ describe('detectUrl — browser-only fixtures', () => { } }); + it('extension mode suppresses disabledValues entries from scan config', async () => { + // The live overlay resolves .impeccable ignoreValues per page and sends + // the survivors as config.disabledValues (issue #639); the detector must + // filter them where the findings are assembled, since the overlay draws + // its own markers from the collected findings. + const normalized = normalizeDesignSystem({ + frontmatter: { + typography: { + display: { fontFamily: 'Avenir Next, Georgia, serif' }, + body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif' }, + }, + colors: { + ink: '#241f1a', + paper: '#f7f4ee', + surface: '#ffffff', + accent: '#b8422e', + border: '#d4c7b9', + }, + rounded: { + sm: '4px', + md: '8px', + '"2xl"': '32px', + full: '999px', + }, + }, + }); + // The JSON-safe payload shape the extension panel and detectUrl inject as + // __IMPECCABLE_CONFIG__.designSystem (serializeDesignSystemForBrowser in + // cli/engine/engines/browser/detect-url.mjs). + const designSystem = { + present: true, + hasFonts: normalized.hasFonts === true, + allowedFonts: Array.from(normalized.allowedFonts || []), + hasColors: normalized.hasColors === true, + allowedColors: Array.from(normalized.allowedColorKeys?.values?.() || []) + .map(entry => entry?.color) + .filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) + .map(color => ({ r: color.r, g: color.g, b: color.b })), + hasRadii: normalized.hasRadii === true, + allowedRadii: (normalized.allowedRadii || []) + .map(entry => Number(entry?.px)) + .filter(px => Number.isFinite(px)), + hasPillRadius: normalized.hasPillRadius === true, + }; + const puppeteer = await import('puppeteer'); + const browser = await puppeteer.default.launch({ + headless: true, + args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [], + }); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + await page.goto(`${baseUrl}/fixtures/antipatterns/design-system.html`, { waitUntil: 'load' }); + const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8'); + await page.evaluate(() => { + document.documentElement.dataset.impeccableExtension = 'true'; + window.__impeccableMessages = []; + window.addEventListener('message', event => { + if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return; + window.__impeccableMessages.push(event.data); + }); + }); + await page.evaluate(browserScript); + const scan = (scanId, disabledValues) => page.evaluate(async (config) => { + window.postMessage({ source: 'impeccable-command', action: 'scan', config }, '*'); + const deadline = Date.now() + 2000; + while ( + Date.now() < deadline && + !window.__impeccableMessages.some(message => + message.source === 'impeccable-results' && message.scanId === config.scanId) + ) { + await new Promise(resolve => setTimeout(resolve, 25)); + } + const resultMessage = window.__impeccableMessages.find(message => + message.source === 'impeccable-results' && message.scanId === config.scanId); + const flat = (resultMessage?.findings || []).flatMap(group => group.findings || []); + return { + total: flat.length, + colors: flat.filter(finding => finding.type === 'design-system-color').length, + fonts: flat + .filter(finding => finding.type === 'design-system-font') + .map(finding => finding.ignoreValue || ''), + }; + }, { scanId, visualContrast: false, designSystem, ...(disabledValues ? { disabledValues } : {}) }); + + const unfiltered = await scan('scan-dv-1'); + assert.ok( + unfiltered.fonts.some(value => /poppins/i.test(value)), + `expected an undocumented poppins font finding, got: ${JSON.stringify(unfiltered)}`, + ); + + const filtered = await scan('scan-dv-2', [{ rule: 'design-system-font', value: 'poppins' }]); + assert.equal( + filtered.fonts.some(value => /poppins/i.test(value)), + false, + `expected the poppins waiver to suppress its finding, got: ${JSON.stringify(filtered)}`, + ); + const waivedCount = unfiltered.fonts.filter(value => /poppins/i.test(value)).length; + assert.equal( + filtered.total, + unfiltered.total - waivedCount, + `expected exactly the waived findings to disappear, got: ${JSON.stringify({ unfiltered, filtered })}`, + ); + assert.equal( + filtered.colors, + unfiltered.colors, + `expected unrelated design-system findings to survive, got: ${JSON.stringify({ unfiltered, filtered })}`, + ); + await page.close(); + } finally { + await browser.close().catch(() => {}); + } + }); + it('browser API: impeccableDetect is pure, impeccableScan decorates', async () => { const puppeteer = await import('puppeteer'); const browser = await puppeteer.default.launch({ diff --git a/tests/live-browser-ignores.test.mjs b/tests/live-browser-ignores.test.mjs new file mode 100644 index 000000000..38fa4f718 --- /dev/null +++ b/tests/live-browser-ignores.test.mjs @@ -0,0 +1,197 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vm from 'node:vm'; + +const REPO_ROOT = process.cwd(); +const SCRIPT = join(REPO_ROOT, 'skill/scripts/live-browser-ignores.js'); + +// Evaluated in the test realm (not a vm context) so the arrays the resolver +// returns share this realm's prototypes and deepEqual compares them plainly. +function loadIgnoresApi() { + const source = readFileSync(SCRIPT, 'utf-8'); + const factory = vm.runInThisContext( + `(function (window) {\n${source}\nreturn window.__IMPECCABLE_LIVE_IGNORES__;\n})`, + { filename: SCRIPT }, + ); + return factory({}); +} + +const resolve = loadIgnoresApi().resolveDetectIgnores; + +const EMPTY = { disabledRules: [], disabledValues: [] }; + +describe('live-browser-ignores resolver', () => { + it('registers a versioned API on the root', () => { + const api = loadIgnoresApi(); + assert.equal(api.version, 1); + assert.equal(typeof api.resolveDetectIgnores, 'function'); + }); + + it('degrades to an empty filter when the config global is missing or malformed', () => { + assert.deepEqual(resolve(), EMPTY); + assert.deepEqual(resolve({ ignores: undefined, pathname: '/index.html' }), EMPTY); + assert.deepEqual(resolve({ ignores: null, pathname: '/index.html' }), EMPTY); + assert.deepEqual(resolve({ ignores: 'nonsense', pathname: '/index.html' }), EMPTY); + assert.deepEqual(resolve({ ignores: {}, pathname: '/index.html' }), EMPTY); + }); + + it('does not spread a string ignoreRules into characters', () => { + // `"ignoreRules": "foo"` in a hand-edited config must disable nothing, + // not look like it disabled three one-letter rules. + const out = resolve({ ignores: { ignoreRules: 'foo' }, pathname: '/index.html' }); + assert.deepEqual(out, EMPTY); + }); + + it('forwards ignoreRules normalized and deduplicated', () => { + const out = resolve({ + ignores: { ignoreRules: ['Dark-Glow', 'dark-glow', ' gradient-text ', '', 42, null] }, + pathname: '/index.html', + }); + assert.deepEqual(out.disabledRules, ['dark-glow', 'gradient-text']); + }); + + it('forwards unscoped value entries and drops malformed ones', () => { + const out = resolve({ + ignores: { + ignoreValues: [ + { rule: 'overused-font', value: 'Geist+Mono' }, + null, + 'not-an-entry', + { rule: '', value: 'x' }, + { rule: 'overused-font', value: '' }, + ], + }, + pathname: '/index.html', + }); + assert.deepEqual(out.disabledValues, [{ rule: 'overused-font', value: 'geist mono' }]); + }); + + it('applies wildcard entries only on pages their globs name', () => { + const ignores = { + roots: ['prototype/'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['prototype/attack-the-soc.html'] }, + ], + }; + const onPage = resolve({ ignores, pathname: '/attack-the-soc.html' }); + assert.deepEqual(onPage.disabledRules, ['dark-glow']); + const elsewhere = resolve({ ignores, pathname: '/index.html' }); + assert.deepEqual(elsewhere.disabledRules, []); + }); + + it('never applies an unscoped wildcard entry, matching the CLI', () => { + // isIgnoredFindingValue in cli/lib/impeccable-config.mjs returns false + // for a wildcard entry with no files; project-wide suppression is + // ignoreRules' job. + const out = resolve({ + ignores: { ignoreValues: [{ rule: 'dark-glow', value: '*' }] }, + pathname: '/index.html', + }); + assert.deepEqual(out, EMPTY); + }); + + it('drops scoped value entries on pages outside their globs', () => { + const ignores = { + roots: ['prototype/'], + ignoreValues: [ + { rule: 'overused-font', value: 'geist mono', files: ['prototype/mgmt-demo.html'] }, + ], + }; + const onPage = resolve({ ignores, pathname: '/mgmt-demo.html' }); + assert.deepEqual(onPage.disabledValues, [{ rule: 'overused-font', value: 'geist mono' }]); + const elsewhere = resolve({ ignores, pathname: '/index.html' }); + assert.deepEqual(elsewhere.disabledValues, []); + }); + + it('does not lend one entry\'s glob prefix to other pages', () => { + // The trap from issue #639: prefixes come only from `roots`, never from + // the ignore globs themselves. An entry scoped to prototype/library/** + // must not suppress on prototype/index.html. + const ignores = { + roots: ['prototype/'], + ignoreValues: [ + { rule: 'em-dash-overuse', value: '*', files: ['prototype/library/**'] }, + ], + }; + const inside = resolve({ ignores, pathname: '/library/buttons.html' }); + assert.deepEqual(inside.disabledRules, ['em-dash-overuse']); + const outside = resolve({ ignores, pathname: '/index.html' }); + assert.deepEqual(outside.disabledRules, []); + }); + + it('resolves root and directory URLs to their index file', () => { + const ignores = { + roots: ['prototype/'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['prototype/index.html'] }, + { rule: 'gradient-text', value: '*', files: ['prototype/news/index.html'] }, + ], + }; + assert.deepEqual(resolve({ ignores, pathname: '/' }).disabledRules, ['dark-glow']); + assert.deepEqual(resolve({ ignores, pathname: '/news/' }).disabledRules, ['gradient-text']); + }); + + it('matches path suffixes like the CLI scoped-file matcher', () => { + // findingMatchesScopedIgnoreFile tries every path suffix of the finding's + // file, so `library/**` written without the prototype/ prefix still + // scopes to the library pages. + const ignores = { + roots: ['prototype/'], + ignoreValues: [ + { rule: 'em-dash-overuse', value: '*', files: ['library/**'] }, + { rule: 'dark-glow', value: '*', files: ['buttons.html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/library/buttons.html' }); + assert.deepEqual(out.disabledRules.sort(), ['dark-glow', 'em-dash-overuse']); + }); + + it('supports the CLI glob dialect, including alternation', () => { + const ignores = { + roots: ['prototype/'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['prototype/{index,about}.html'] }, + { rule: 'gradient-text', value: '*', files: ['prototype/page-?.html'] }, + ], + }; + assert.deepEqual(resolve({ ignores, pathname: '/about.html' }).disabledRules, ['dark-glow']); + assert.deepEqual(resolve({ ignores, pathname: '/page-3.html' }).disabledRules, ['gradient-text']); + assert.deepEqual(resolve({ ignores, pathname: '/page-33.html' }).disabledRules, []); + }); + + it('treats glob metacharacters in filenames literally', () => { + const ignores = { + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['pricing (v2).html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/pricing (v2).html' }); + assert.deepEqual(out.disabledRules, ['dark-glow']); + const near = resolve({ ignores, pathname: '/pricing xv2y.html' }); + assert.deepEqual(near.disabledRules, []); + }); + + it('accepts a single `file` string alongside `files`', () => { + const out = resolve({ + ignores: { + ignoreValues: [{ rule: 'dark-glow', value: '*', file: 'index.html' }], + }, + pathname: '/index.html', + }); + assert.deepEqual(out.disabledRules, ['dark-glow']); + }); + + it('survives malformed roots and percent-escapes without throwing', () => { + const out = resolve({ + ignores: { + roots: 7, + ignoreRules: ['dark-glow'], + ignoreValues: [{ rule: 'gradient-text', value: '*', files: ['broken%.html'] }], + }, + pathname: '/broken%.html', + }); + assert.deepEqual(out.disabledRules.sort(), ['dark-glow', 'gradient-text']); + }); +}); diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index 6be47eea7..5c984c47f 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -714,8 +714,13 @@ describe('live-browser.js regression guards', () => { ); assert.match( SOURCE, - /function requestDetectScan\(\)[\s\S]{0,240}?const scanId = String\(\+\+detectScanSeq\);[\s\S]{0,80}?activeDetectScanId = scanId;[\s\S]{0,160}?config: \{ scanId \}/, - 'Detect scans must send a fresh scan id to the detector', + /function requestDetectScan\(\)[\s\S]{0,240}?const scanId = String\(\+\+detectScanSeq\);[\s\S]{0,80}?activeDetectScanId = scanId;[\s\S]{0,1400}?config: \{ scanId, disabledRules: ignores\.disabledRules, disabledValues: ignores\.disabledValues \}/, + 'Detect scans must send a fresh scan id plus the resolved project waivers to the detector', + ); + assert.match( + SOURCE, + /typeof ignoresApi\?\.resolveDetectIgnores === 'function'[\s\S]{0,300}?: \{ disabledRules: \[\], disabledValues: \[\] \}/, + 'a cached live.js without the ignores resolver part must still scan, just unfiltered', ); assert.match( SOURCE, diff --git a/tests/live-browser-script-parts.test.mjs b/tests/live-browser-script-parts.test.mjs index b8db88631..930f76fc9 100644 --- a/tests/live-browser-script-parts.test.mjs +++ b/tests/live-browser-script-parts.test.mjs @@ -12,13 +12,15 @@ describe('live browser script parts', () => { it('resolves the canonical browser script order', () => { const parts = resolveLiveBrowserScriptParts('/repo/skill/scripts'); - assert.deepEqual(parts.map((part) => part.name), ['session-state', 'dom-helpers', 'browser-ui']); + assert.deepEqual(parts.map((part) => part.name), ['session-state', 'dom-helpers', 'project-ignores', 'browser-ui']); assert.equal(parts[0].file, 'live-browser-session.js'); assert.equal(parts[1].file, 'live-browser-dom.js'); - assert.equal(parts[2].file, 'live-browser.js'); + assert.equal(parts[2].file, 'live-browser-ignores.js'); + assert.equal(parts[3].file, 'live-browser.js'); assert.equal(parts[0].path, path.join('/repo/skill/scripts', 'live-browser-session.js')); assert.equal(parts[1].path, path.join('/repo/skill/scripts', 'live-browser-dom.js')); - assert.equal(parts[2].path, path.join('/repo/skill/scripts', 'live-browser.js')); + assert.equal(parts[2].path, path.join('/repo/skill/scripts', 'live-browser-ignores.js')); + assert.equal(parts[3].path, path.join('/repo/skill/scripts', 'live-browser.js')); }); it('asserts missing script parts by name', () => { @@ -37,6 +39,7 @@ describe('live browser script parts', () => { assert.deepEqual(loaded.map((part) => part.source), [ 'source:live-browser-session.js', 'source:live-browser-dom.js', + 'source:live-browser-ignores.js', 'source:live-browser.js', ]); }); @@ -50,16 +53,20 @@ describe('live browser script parts', () => { parts: [ { name: 'session-state', file: 'live-browser-session.js', source: 'window.__SESSION_PART__ = true;' }, { name: 'dom-helpers', file: 'live-browser-dom.js', source: 'window.__DOM_PART__ = true;' }, + { name: 'project-ignores', file: 'live-browser-ignores.js', source: 'window.__IGNORES_PART__ = true;' }, { name: 'browser-ui', file: 'live-browser.js', source: 'window.__BROWSER_PART__ = true;' }, ], + projectIgnores: { ignoreRules: ['dark-glow'], ignoreValues: [], roots: ['prototype/'] }, }); const tokenIndex = script.indexOf('window.__IMPECCABLE_TOKEN__'); const portIndex = script.indexOf('window.__IMPECCABLE_PORT__'); const commandPrefixIndex = script.indexOf('window.__IMPECCABLE_COMMAND_PREFIX__'); const vocabIndex = script.indexOf('window.__IMPECCABLE_VOCAB__'); + const projectIgnoresIndex = script.indexOf('window.__IMPECCABLE_PROJECT_IGNORES__'); const sessionIndex = script.indexOf('window.__SESSION_PART__'); const domIndex = script.indexOf('window.__DOM_PART__'); + const ignoresIndex = script.indexOf('window.__IGNORES_PART__'); const browserIndex = script.indexOf('window.__BROWSER_PART__'); assert.ok(tokenIndex !== -1); @@ -67,11 +74,26 @@ describe('live browser script parts', () => { assert.ok(portIndex < commandPrefixIndex); assert.ok(commandPrefixIndex < vocabIndex); assert.match(script, /window\.__IMPECCABLE_COMMAND_PREFIX__ = "\$"/); - assert.ok(vocabIndex < sessionIndex); + assert.ok(vocabIndex < projectIgnoresIndex); + assert.ok(projectIgnoresIndex < sessionIndex); assert.ok(sessionIndex < domIndex); - assert.ok(domIndex < browserIndex); + assert.ok(domIndex < ignoresIndex); + assert.ok(ignoresIndex < browserIndex); + assert.match(script, /window\.__IMPECCABLE_PROJECT_IGNORES__ = \{"ignoreRules":\["dark-glow"\],"ignoreValues":\[\],"roots":\["prototype\/"\]\};/); assert.match(script, /impeccable live script part: session-state \(live-browser-session\.js\)/); assert.match(script, /impeccable live script part: dom-helpers \(live-browser-dom\.js\)/); + assert.match(script, /impeccable live script part: project-ignores \(live-browser-ignores\.js\)/); assert.match(script, /impeccable live script part: browser-ui \(live-browser\.js\)/); }); + + it('serializes null project ignores when none are passed', () => { + const script = assembleLiveBrowserScript({ + token: 'token-a', + port: 8421, + vocabulary: [], + parts: [], + }); + + assert.match(script, /window\.__IMPECCABLE_PROJECT_IGNORES__ = null;/); + }); }); From ce1c9f8dad66ecc792d3fb09b57759742011c1dc Mon Sep 17 00:00:00 2001 From: Guitaraholic Date: Mon, 24 Aug 2026 10:03:04 +0100 Subject: [PATCH 02/16] Fix: a waiver scoped to one served root must not hide findings on another Greptile's review found a real bug in the new resolver. When the live config lists pages under more than one folder (src/**/*.html and public/**/*.html), the overlay treated a URL like /foo.html as src/foo.html and public/foo.html at the same time. A waiver written only for src/foo.html could then hide a finding on the page actually served from public/foo.html. That fails in the worst direction: a real finding disappears and nothing says so. The overlay can never look up the right file. The live server does not serve the pages; the project's own dev or static server does, and its URL-to-file mapping is invisible from here. So the fix stops guessing: a file-scoped waiver now applies only when it matches the URL path itself, which is true whichever folder serves the page, or when it matches under every configured folder, so no possible reading disagrees. Anything ambiguous shows the finding, which is also what the CLI reports for the file really being served. With a single configured root, the common case, nothing changes: the new rule reduces to the old behaviour exactly. Multi-root projects keep three ways to write a waiver that still applies: name the file under each folder, use the bare path, or use **/. Two new unit tests pin the ambiguous case and the safe spellings. Co-Authored-By: Claude Fable 5 --- skill/scripts/live-browser-ignores.js | 49 +++++++++++++++++---------- tests/live-browser-ignores.test.mjs | 31 +++++++++++++++++ 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/skill/scripts/live-browser-ignores.js b/skill/scripts/live-browser-ignores.js index fd78fe21b..649302fa6 100644 --- a/skill/scripts/live-browser-ignores.js +++ b/skill/scripts/live-browser-ignores.js @@ -89,6 +89,13 @@ // Each prefixed path also contributes its slash suffixes, mirroring // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which // matches globs against every path suffix of the finding's file). + // + // With several roots configured, one URL has several possible identities + // and the browser cannot tell which root actually serves it. The rooted + // groups are therefore kept separate: matchesScope treats a root-prefixed + // match as valid only when it holds under every root, so a waiver scoped + // to src/foo.html never hides a finding on a page served from + // public/foo.html. With a single root this reduces to plain matching. function pageCandidates(pathname, roots) { let pagePath = String(pathname || ''); try { @@ -101,33 +108,39 @@ // name files. Without this, /news/ never matches prototype/news/index.html. if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; - const prefixes = ['']; + const suffixesOf = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + const out = []; + for (let i = 0; i < parts.length; i++) { + out.push(parts.slice(i).join('/')); + } + return out; + }; + + const rooted = []; for (const entry of Array.isArray(roots) ? roots : []) { if (typeof entry !== 'string') continue; - prefixes.push(entry === '' || entry.endsWith('/') ? entry : entry + '/'); + const prefix = entry === '' || entry.endsWith('/') ? entry : entry + '/'; + rooted.push(suffixesOf(prefix + pagePath)); } - - const candidates = new Set(); - for (const prefix of prefixes) { - const full = prefix + pagePath; - const parts = full.split('/').filter(Boolean); - for (let i = 0; i < parts.length; i++) { - candidates.add(parts.slice(i).join('/')); - } - } - return [...candidates]; + return { bare: suffixesOf(pagePath), rooted }; } function matchesScope(globs, candidates) { - return globs.some((glob) => { - let re; + const regexes = []; + for (const glob of globs) { try { - re = globToRegex(String(glob)); + regexes.push(globToRegex(String(glob))); } catch { - return false; + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. } - return candidates.some((candidate) => re.test(candidate)); - }); + } + if (regexes.length === 0) return false; + const hits = (paths) => paths.some((path) => regexes.some((re) => re.test(path))); + // Matches on the URL path itself hold whichever root serves the page. + if (hits(candidates.bare)) return true; + // Root-prefixed matches only hold if no possible identity disagrees. + return candidates.rooted.length > 0 && candidates.rooted.every(hits); } /** diff --git a/tests/live-browser-ignores.test.mjs b/tests/live-browser-ignores.test.mjs index 38fa4f718..69586d86a 100644 --- a/tests/live-browser-ignores.test.mjs +++ b/tests/live-browser-ignores.test.mjs @@ -183,6 +183,37 @@ describe('live-browser-ignores resolver', () => { assert.deepEqual(out.disabledRules, ['dark-glow']); }); + it('does not apply a waiver scoped to one root when several roots could serve the URL', () => { + // With src/**/*.html and public/**/*.html both configured, /foo.html + // could be served from either root. A waiver naming only src/foo.html + // must not hide a finding on a page actually served from + // public/foo.html; ambiguity resolves to showing the finding. + const ignores = { + roots: ['src/', 'public/'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['src/foo.html'] }, + { rule: 'gradient-text', value: 'teal', files: ['src/foo.html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/foo.html' }); + assert.deepEqual(out, EMPTY); + }); + + it('applies a scoped waiver under several roots when every identity matches', () => { + const ignores = { + roots: ['src/', 'public/'], + ignoreValues: [ + // Two globs covering both identities. + { rule: 'dark-glow', value: '*', files: ['src/foo.html', 'public/foo.html'] }, + // A bare-path glob holds whichever root serves the page. + { rule: 'em-dash-overuse', value: '*', files: ['foo.html'] }, + { rule: 'gradient-text', value: '*', files: ['**/foo.html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/foo.html' }); + assert.deepEqual(out.disabledRules.sort(), ['dark-glow', 'em-dash-overuse', 'gradient-text']); + }); + it('survives malformed roots and percent-escapes without throwing', () => { const out = resolve({ ignores: { From 45943c3b1f98c7eccb8bdd37229e2aa4723d0d99 Mon Sep 17 00:00:00 2001 From: Guitaraholic Date: Mon, 24 Aug 2026 10:24:53 +0100 Subject: [PATCH 03/16] Fix: assert only the common ancestor of the glob roots as a URL prefix Cursor's review caught the previous commit over-correcting. One tree listed at two depths (prototype/*.html plus prototype/library/**/*.html) derived two roots, and requiring a waiver to match under both stopped a normal project-relative waiver like prototype/index.html from applying anywhere. The rule both reviews were circling is simpler: one live session is served by one server, so a single document root must sit at or above every configured page. The only prefix the resolver can safely assert is the deepest common ancestor of the glob roots. Nested roots collapse to their shared tree, so normal waivers keep applying. Disjoint roots (src/ and public/) share nothing, so no prefix is asserted and only the URL path itself matches, which keeps the earlier fix intact: a src/foo.html waiver still cannot hide a finding on a page served from public/foo.html. This also deletes the match-under-every-root machinery from the previous commit; with a single asserted prefix, plain matching is enough. Also switches the new test file to derive the repo root from import.meta.url rather than process.cwd(), per review. Co-Authored-By: Claude Fable 5 --- skill/scripts/live-browser-ignores.js | 65 +++++++++++++++------------ tests/live-browser-ignores.test.mjs | 56 ++++++++++++++--------- 2 files changed, 70 insertions(+), 51 deletions(-) diff --git a/skill/scripts/live-browser-ignores.js b/skill/scripts/live-browser-ignores.js index 649302fa6..92df1132b 100644 --- a/skill/scripts/live-browser-ignores.js +++ b/skill/scripts/live-browser-ignores.js @@ -90,12 +90,17 @@ // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which // matches globs against every path suffix of the finding's file). // - // With several roots configured, one URL has several possible identities - // and the browser cannot tell which root actually serves it. The rooted - // groups are therefore kept separate: matchesScope treats a root-prefixed - // match as valid only when it holds under every root, so a waiver scoped - // to src/foo.html never hides a finding on a page served from - // public/foo.html. With a single root this reduces to plain matching. + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. function pageCandidates(pathname, roots) { let pagePath = String(pathname || ''); try { @@ -108,39 +113,41 @@ // name files. Without this, /news/ never matches prototype/news/index.html. if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; - const suffixesOf = (fullPath) => { - const parts = fullPath.split('/').filter(Boolean); - const out = []; - for (let i = 0; i < parts.length; i++) { - out.push(parts.slice(i).join('/')); - } - return out; - }; - - const rooted = []; + const prefixes = []; for (const entry of Array.isArray(roots) ? roots : []) { if (typeof entry !== 'string') continue; - const prefix = entry === '' || entry.endsWith('/') ? entry : entry + '/'; - rooted.push(suffixesOf(prefix + pagePath)); + prefixes.push(entry.split('/').filter(Boolean)); } - return { bare: suffixesOf(pagePath), rooted }; + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; } function matchesScope(globs, candidates) { - const regexes = []; - for (const glob of globs) { + return globs.some((glob) => { + let re; try { - regexes.push(globToRegex(String(glob))); + re = globToRegex(String(glob)); } catch { // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; } - } - if (regexes.length === 0) return false; - const hits = (paths) => paths.some((path) => regexes.some((re) => re.test(path))); - // Matches on the URL path itself hold whichever root serves the page. - if (hits(candidates.bare)) return true; - // Root-prefixed matches only hold if no possible identity disagrees. - return candidates.rooted.length > 0 && candidates.rooted.every(hits); + return candidates.some((candidate) => re.test(candidate)); + }); } /** diff --git a/tests/live-browser-ignores.test.mjs b/tests/live-browser-ignores.test.mjs index 69586d86a..811eed6d7 100644 --- a/tests/live-browser-ignores.test.mjs +++ b/tests/live-browser-ignores.test.mjs @@ -1,10 +1,11 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import vm from 'node:vm'; -const REPO_ROOT = process.cwd(); +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const SCRIPT = join(REPO_ROOT, 'skill/scripts/live-browser-ignores.js'); // Evaluated in the test realm (not a vm context) so the arrays the resolver @@ -183,35 +184,46 @@ describe('live-browser-ignores resolver', () => { assert.deepEqual(out.disabledRules, ['dark-glow']); }); - it('does not apply a waiver scoped to one root when several roots could serve the URL', () => { - // With src/**/*.html and public/**/*.html both configured, /foo.html - // could be served from either root. A waiver naming only src/foo.html - // must not hide a finding on a page actually served from - // public/foo.html; ambiguity resolves to showing the finding. + it('asserts no prefix when the configured roots share no common ancestor', () => { + // With src/**/*.html and public/**/*.html both configured, no single + // document root maps /foo.html to a unique project file, so no prefix + // is asserted. A waiver naming src/foo.html must not hide a finding on + // a page served from public/foo.html; ambiguity resolves to showing + // the finding. Bare-path spellings still apply whichever root serves it. const ignores = { roots: ['src/', 'public/'], ignoreValues: [ { rule: 'dark-glow', value: '*', files: ['src/foo.html'] }, - { rule: 'gradient-text', value: 'teal', files: ['src/foo.html'] }, - ], - }; - const out = resolve({ ignores, pathname: '/foo.html' }); - assert.deepEqual(out, EMPTY); - }); - - it('applies a scoped waiver under several roots when every identity matches', () => { - const ignores = { - roots: ['src/', 'public/'], - ignoreValues: [ - // Two globs covering both identities. - { rule: 'dark-glow', value: '*', files: ['src/foo.html', 'public/foo.html'] }, - // A bare-path glob holds whichever root serves the page. + { rule: 'clipped-overflow-container', value: '*', files: ['src/foo.html', 'public/foo.html'] }, { rule: 'em-dash-overuse', value: '*', files: ['foo.html'] }, { rule: 'gradient-text', value: '*', files: ['**/foo.html'] }, ], }; const out = resolve({ ignores, pathname: '/foo.html' }); - assert.deepEqual(out.disabledRules.sort(), ['dark-glow', 'em-dash-overuse', 'gradient-text']); + assert.deepEqual(out.disabledRules.sort(), ['em-dash-overuse', 'gradient-text']); + }); + + it('reduces nested roots to their common ancestor so normal waivers keep applying', () => { + // Globs at two depths in one tree (prototype/*.html plus + // prototype/library/**/*.html) derive the prefixes prototype/ and + // prototype/library/. Those are not alternative identities: one server + // serves both, so the document root sits at their common ancestor and + // a project-relative waiver like prototype/index.html must apply. + const ignores = { + roots: ['prototype/', 'prototype/library/'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['prototype/index.html'] }, + { rule: 'em-dash-overuse', value: '*', files: ['prototype/library/**'] }, + ], + }; + assert.deepEqual( + resolve({ ignores, pathname: '/index.html' }).disabledRules, + ['dark-glow'], + ); + assert.deepEqual( + resolve({ ignores, pathname: '/library/buttons.html' }).disabledRules, + ['em-dash-overuse'], + ); }); it('survives malformed roots and percent-escapes without throwing', () => { From 31dcc687c66ed0037dd4ebfe3ba474e2b8a7b06b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:14:29 +0000 Subject: [PATCH 04/16] Sync generated provider output --- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .pi/skills/impeccable/scripts/live-browser.js | 16 +- .pi/skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- .../detector/browser/injected/index.mjs | 63 ++++++ .../detector/detect-antipatterns-browser.js | 63 ++++++ .../scripts/live-browser-ignores.js | 201 ++++++++++++++++++ .../skills/impeccable/scripts/live-browser.js | 16 +- .../skills/impeccable/scripts/live-server.mjs | 50 +++++ .../scripts/live/browser-script-parts.mjs | 9 +- 96 files changed, 6400 insertions(+), 32 deletions(-) create mode 100644 .agents/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .claude/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .cursor/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .gemini/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .github/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .grok/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .hermes/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .kiro/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .opencode/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .pi/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .qoder/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .rovodev/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .trae-cn/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .trae/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 .vibe/skills/impeccable/scripts/live-browser-ignores.js create mode 100644 plugin/skills/impeccable/scripts/live-browser-ignores.js diff --git a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.agents/skills/impeccable/scripts/live-browser-ignores.js b/.agents/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.agents/skills/impeccable/scripts/live/browser-script-parts.mjs b/.agents/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.agents/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.agents/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.claude/skills/impeccable/scripts/live-browser-ignores.js b/.claude/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.claude/skills/impeccable/scripts/live/browser-script-parts.mjs b/.claude/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.claude/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.claude/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.cursor/skills/impeccable/scripts/live-browser-ignores.js b/.cursor/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.cursor/skills/impeccable/scripts/live/browser-script-parts.mjs b/.cursor/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.cursor/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.cursor/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.gemini/skills/impeccable/scripts/live-browser-ignores.js b/.gemini/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.gemini/skills/impeccable/scripts/live/browser-script-parts.mjs b/.gemini/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.gemini/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.gemini/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.github/skills/impeccable/scripts/live-browser-ignores.js b/.github/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.github/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.github/skills/impeccable/scripts/live/browser-script-parts.mjs b/.github/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.github/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.github/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.grok/skills/impeccable/scripts/live-browser-ignores.js b/.grok/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.grok/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.grok/skills/impeccable/scripts/live-browser.js b/.grok/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.grok/skills/impeccable/scripts/live-browser.js +++ b/.grok/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.grok/skills/impeccable/scripts/live-server.mjs b/.grok/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.grok/skills/impeccable/scripts/live-server.mjs +++ b/.grok/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.grok/skills/impeccable/scripts/live/browser-script-parts.mjs b/.grok/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.grok/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.grok/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.hermes/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.hermes/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.hermes/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.hermes/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.hermes/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.hermes/skills/impeccable/scripts/live-browser-ignores.js b/.hermes/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.hermes/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.hermes/skills/impeccable/scripts/live-browser.js b/.hermes/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.hermes/skills/impeccable/scripts/live-browser.js +++ b/.hermes/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.hermes/skills/impeccable/scripts/live-server.mjs b/.hermes/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.hermes/skills/impeccable/scripts/live-server.mjs +++ b/.hermes/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.hermes/skills/impeccable/scripts/live/browser-script-parts.mjs b/.hermes/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.hermes/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.hermes/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.kiro/skills/impeccable/scripts/live-browser-ignores.js b/.kiro/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.kiro/skills/impeccable/scripts/live/browser-script-parts.mjs b/.kiro/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.kiro/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.kiro/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.opencode/skills/impeccable/scripts/live-browser-ignores.js b/.opencode/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.opencode/skills/impeccable/scripts/live/browser-script-parts.mjs b/.opencode/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.opencode/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.opencode/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.pi/skills/impeccable/scripts/live-browser-ignores.js b/.pi/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.pi/skills/impeccable/scripts/live/browser-script-parts.mjs b/.pi/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.pi/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.pi/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.qoder/skills/impeccable/scripts/live-browser-ignores.js b/.qoder/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.qoder/skills/impeccable/scripts/live-browser.js +++ b/.qoder/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.qoder/skills/impeccable/scripts/live/browser-script-parts.mjs b/.qoder/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.qoder/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.qoder/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.rovodev/skills/impeccable/scripts/live-browser-ignores.js b/.rovodev/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.rovodev/skills/impeccable/scripts/live/browser-script-parts.mjs b/.rovodev/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.rovodev/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.rovodev/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.trae-cn/skills/impeccable/scripts/live-browser-ignores.js b/.trae-cn/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.trae-cn/skills/impeccable/scripts/live/browser-script-parts.mjs b/.trae-cn/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.trae-cn/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.trae-cn/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.trae/skills/impeccable/scripts/live-browser-ignores.js b/.trae/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.trae/skills/impeccable/scripts/live/browser-script-parts.mjs b/.trae/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.trae/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.trae/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/.vibe/skills/impeccable/scripts/live-browser-ignores.js b/.vibe/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/.vibe/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/.vibe/skills/impeccable/scripts/live-browser.js b/.vibe/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/.vibe/skills/impeccable/scripts/live-browser.js +++ b/.vibe/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/.vibe/skills/impeccable/scripts/live-server.mjs b/.vibe/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/.vibe/skills/impeccable/scripts/live-server.mjs +++ b/.vibe/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/.vibe/skills/impeccable/scripts/live/browser-script-parts.mjs b/.vibe/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/.vibe/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/.vibe/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); diff --git a/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs b/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs index 6eb971450..ed91c8fb1 100644 --- a/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -1675,6 +1675,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 8893bb323..f40a768f7 100644 --- a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -8334,6 +8334,69 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, document.body, mapped); } + // Value-level suppression (issue #639). `disabledRules` above handles + // whole rules; this applies the config's remaining ignoreValues entries, + // which the CLI filters through isIgnoredFindingValue in + // cli/lib/impeccable-config.mjs, so a project waiver like + // overused-font = "geist mono" reaches the overlay and extension too. + const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase(); + const _disabledValues = EXTENSION_MODE + ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : []) + .filter(e => e && typeof e === 'object' && e.rule && e.value) + .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) })) + : []; + if (_disabledValues.length > 0) { + // The six rules whose findings carry a matchable value; keep in step + // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs. + // Everything else is suppressed by rule or by file scope, both already + // resolved into disabledRules before the scan message was sent. + const _directValueRules = new Set([ + 'overused-font', + 'bounce-easing', + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', + ]); + // The design-system checks set `ignoreValue` on their findings; the + // detail fallbacks catch overused-font, whose value lives in its + // sentence. Two CLI matchers are not mirrored here: the motion + // extractor (a value-scoped bounce-easing waiver only matches when the + // finding carries ignoreValue directly) and the design-system-color + // color-equality fallback (a waiver written as rgb() will not match a + // finding reported as hex; store the reported form). + const _findingValue = (f) => { + if (!f || !_directValueRules.has(f.type || f.id)) return ''; + const direct = f.ignoreValue || f.value; + if (direct) return _normValue(direct); + for (const text of [f.detail, f.snippet]) { + if (typeof text !== 'string' || !text) continue; + const primary = text.match(/Primary font:\s*([^()\n;]+)/i); + if (primary) return _normValue(primary[1]); + const google = text.match(/Google Fonts:\s*([^()\n;]+)/i); + if (google) return _normValue(google[1]); + const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i); + if (family) return _normValue(family[1]); + } + return ''; + }; + const _valueIgnored = (f) => { + const value = _findingValue(f); + if (!value) return false; + const rule = f.type || f.id; + return _disabledValues.some(e => e.rule === rule && e.value === value); + }; + for (const [el, list] of [...groupMap.entries()]) { + const kept = list.filter(f => !_valueIgnored(f)); + if (kept.length > 0) groupMap.set(el, kept); + else groupMap.delete(el); + } + for (let i = pageLevelFindings.length - 1; i >= 0; i--) { + if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1); + } + } + return { groupMap, allFindings: browserFindingsFromMap(groupMap), diff --git a/plugin/skills/impeccable/scripts/live-browser-ignores.js b/plugin/skills/impeccable/scripts/live-browser-ignores.js new file mode 100644 index 000000000..92df1132b --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-browser-ignores.js @@ -0,0 +1,201 @@ +/** + * Browser-side resolution of project detector waivers for Impeccable live mode. + * + * The live server serializes `.impeccable/config.json` + `config.local.json` + * detector ignores (plus the served-root prefixes from the inject config's + * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part + * resolves that config against the current page's URL path when a detect scan + * starts, so the overlay suppresses the same findings the CLI and the edit + * hook do (issue #639). + * + * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs: + * 1. `ignoreRules` suppress a rule project-wide. + * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the + * files their globs name. The CLI never applies an unscoped wildcard + * (isIgnoredFindingValue returns false for it), so neither does this. + * 3. Remaining `ignoreValues` entries match on the finding's own value; + * those are forwarded as `disabledValues` for the detector bundle to + * apply where the findings are assembled. + * + * Kept separate from live-browser.js so the glob and page-scope logic can be + * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full + * overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in + // cli/lib/impeccable-config.mjs. + function normalizeIgnoreRule(rule) { + return String(rule || '').trim().toLowerCase(); + } + + function normalizeIgnoreValue(value) { + return String(value || '') + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\+/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + } + + // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation. + // Keep in step with globToRegex in cli/lib/impeccable-config.mjs. + function globToRegex(glob) { + let re = '^'; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i += 1; + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (c === '{') { + const end = glob.indexOf('}', i); + if (end === -1) { re += '\\{'; i += 1; continue; } + const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&')); + re += `(?:${parts.join('|')})`; + i = end + 1; + } else if (/[.+^$()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + re += '$'; + return new RegExp(re); + } + + // The project-relative paths this page could be known as. Ignore globs are + // project-relative (prototype/foo.html) and the URL is site-relative + // (/foo.html), because a static server's root usually sits inside the + // project; `roots` carries that prefix. The server reads it from the inject + // config's own `files` globs, which already state where the served pages + // are. Do not derive it from the ignore globs: a single entry scoped to + // prototype/library/** would then lend prototype/library/ as a candidate + // prefix to every page, and that rule would suppress site-wide. + // + // Each prefixed path also contributes its slash suffixes, mirroring + // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which + // matches globs against every path suffix of the finding's file). + // + // One live session is served by one server, so a single document root must + // sit at or above every configured page. The only prefix that can safely + // be asserted is therefore the deepest common ancestor of the glob roots. + // Treating each glob's own prefix as an identity goes wrong in both + // directions: disjoint roots (src/ and public/) invent simultaneous + // identities for one URL, so a waiver scoped to src/foo.html hides a + // finding on a page served from public/foo.html; nested roots (prototype/ + // and prototype/library/, from globs at two depths in one tree) are not + // alternatives at all, and demanding a waiver match under both stops + // prototype/index.html from applying anywhere. When the globs share no + // common root, no prefix is asserted and only the URL path itself matches. + function pageCandidates(pathname, roots) { + let pagePath = String(pathname || ''); + try { + pagePath = decodeURIComponent(pagePath); + } catch { + // Malformed percent-escape: match on the raw path rather than throwing. + } + pagePath = pagePath.replace(/^\/+/, ''); + // A directory URL serves that directory's index, and the ignore globs + // name files. Without this, /news/ never matches prototype/news/index.html. + if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html'; + + const prefixes = []; + for (const entry of Array.isArray(roots) ? roots : []) { + if (typeof entry !== 'string') continue; + prefixes.push(entry.split('/').filter(Boolean)); + } + let common = prefixes.length > 0 ? prefixes[0] : []; + for (const segments of prefixes.slice(1)) { + let i = 0; + while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1; + common = common.slice(0, i); + } + + const candidates = new Set(); + const addSuffixes = (fullPath) => { + const parts = fullPath.split('/').filter(Boolean); + for (let i = 0; i < parts.length; i++) { + candidates.add(parts.slice(i).join('/')); + } + }; + addSuffixes(pagePath); + if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath); + return [...candidates]; + } + + function matchesScope(globs, candidates) { + return globs.some((glob) => { + let re; + try { + re = globToRegex(String(glob)); + } catch { + // Malformed glob: skip it, as matchesAnyGlob does in the CLI. + return false; + } + return candidates.some((candidate) => re.test(candidate)); + }); + } + + /** + * Resolve the serialized project ignores for one page. + * + * @param {object} options + * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__, + * in whatever state it arrived: absent, null, or hand-edited into the + * wrong shape. Every read tolerates that and degrades to no filtering. + * @param {string} options.pathname location.pathname of the scanned page. + * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}> }} + */ + function resolveDetectIgnores({ ignores, pathname } = {}) { + const config = ignores && typeof ignores === 'object' ? ignores : {}; + const asArray = (value) => (Array.isArray(value) ? value : []); + const candidates = pageCandidates(pathname, config.roots); + + const disabledRules = new Set( + asArray(config.ignoreRules) + .filter((rule) => typeof rule === 'string') + .map(normalizeIgnoreRule) + .filter(Boolean), + ); + const disabledValues = []; + + for (const entry of asArray(config.ignoreValues)) { + if (!entry || typeof entry !== 'object') continue; + const rule = normalizeIgnoreRule(entry.rule); + const value = normalizeIgnoreValue(entry.value); + if (!rule || !value) continue; + const files = [ + ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []), + ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()), + ]; + if (value === '*') { + // Wildcards suppress their rule only inside the files they name. + if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule); + continue; + } + if (files.length > 0 && !matchesScope(files, candidates)) continue; + disabledValues.push({ rule, value }); + } + + return { disabledRules: [...disabledRules], disabledValues }; + } + + root.__IMPECCABLE_LIVE_IGNORES__ = { + version: 1, + resolveDetectIgnores, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index 69d227b0a..e9435f8c2 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -11143,10 +11143,24 @@ void main() { const scanId = String(++detectScanSeq); activeDetectScanId = scanId; pendingDetectScanId = scanId; + // Send the project's detector waivers with the scan so the overlay + // filters the same findings the CLI and the edit hook do (issue #639). + // live-browser-ignores.js resolves .impeccable config for this page: + // ignoreRules suppress outright, wildcard ignoreValues suppress their + // rule in the files they name, and the rest match on the finding's own + // value inside the detector. Guarded so a stale cached live.js without + // the resolver part still scans, just unfiltered as before. + const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__; + const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function' + ? ignoresApi.resolveDetectIgnores({ + ignores: window.__IMPECCABLE_PROJECT_IGNORES__, + pathname: location.pathname, + }) + : { disabledRules: [], disabledValues: [] }; window.postMessage({ source: 'impeccable-command', action: 'scan', - config: { scanId }, + config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues }, }, '*'); } diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index dafa8bd0c..ab298843b 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -22,6 +22,7 @@ import net from 'node:net'; import { fileURLToPath } from 'node:url'; import { parseDesignMd } from './lib/design-parser.mjs'; import { loadContext } from './context.mjs'; +import { readConfig as readHookConfig } from './hook-lib.mjs'; import { assembleLiveBrowserScript, assertLiveBrowserScriptParts, @@ -45,6 +46,7 @@ import { readLiveServerInfo, removeLiveServerInfo, resolveDesignSidecarPath, + resolveLiveConfigPath, writeLiveServerInfo, } from './lib/impeccable-paths.mjs'; import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs'; @@ -694,6 +696,51 @@ function isLoopbackOrigin(origin) { // HTTP request handler // --------------------------------------------------------------------------- +// Project detector waivers for the browser overlay (issue #639). The CLI and +// the edit hook filter findings through .impeccable/config.json; the overlay +// scans in the browser, so the same config rides along in the /live.js +// prelude and live-browser-ignores.js applies it per page at scan time. +function readProjectDetectorIgnores() { + // readConfig merges config.json with the gitignored config.local.json and + // type-checks both, exactly as the edit hook reads the same pair. + const config = readHookConfig(process.cwd()); + return { + ignoreRules: Array.isArray(config.ignoreRules) ? config.ignoreRules : [], + // Serve only what the browser matches on; createdAt/reason stay local. + ignoreValues: (Array.isArray(config.ignoreValues) ? config.ignoreValues : []).map((entry) => ({ + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + })), + roots: readLiveServedRoots(), + }; +} + +// Where the served pages live inside the project. Ignore globs are +// project-relative and the browser only knows its URL path, so it needs the +// prefix; the inject config's own `files` globs are the authority on it. +// Deriving it from the ignore globs instead fails silently: one entry scoped +// to prototype/library/** would lend prototype/library/ as a candidate prefix +// to every page, and that rule would suppress site-wide. +function readLiveServedRoots() { + try { + const configPath = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname }); + const live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const files = Array.isArray(live?.files) ? live.files : []; + return [...new Set(files + .filter((glob) => typeof glob === 'string' && glob) + .map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return cut > -1 ? head.slice(0, cut + 1) : ''; + }))]; + } catch { + // No readable inject config: the browser matches URL paths as-is. + return []; + } +} + function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); @@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { commandPrefix: IMPECCABLE_COMMAND_PREFIX, appRoot: process.cwd(), parts, + // Read per request rather than cached, so editing the config and + // reloading the tab is enough to pick up a new waiver. + projectIgnores: readProjectDetectorIgnores(), }); res.writeHead(200, { 'Content-Type': 'application/javascript', diff --git a/plugin/skills/impeccable/scripts/live/browser-script-parts.mjs b/plugin/skills/impeccable/scripts/live/browser-script-parts.mjs index 720709a99..347a0f3f1 100644 --- a/plugin/skills/impeccable/scripts/live/browser-script-parts.mjs +++ b/plugin/skills/impeccable/scripts/live/browser-script-parts.mjs @@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs' export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), + Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); @@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({ // so tests can assemble with a stand-in. uiSurfaces = LIVE_UI_SURFACES, mountContract = LIVE_CHROME_MOUNT_CONTRACT, + // Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from + // .impeccable config by live-server.mjs. live-browser-ignores.js resolves + // them against the page when a detect scan starts, so the overlay filters + // the same findings the CLI and the edit hook do (issue #639). + projectIgnores = null, }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + @@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({ // repo's tests, the impeccable-site Live UI lab) import the module directly, // which is what keeps the two from drifting. `window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` + - `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`; + `window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` + + `window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`; const body = parts.map((part) => { const file = part.file || path.basename(part.path || ''); From 3df4c4b10d440857ab48a6301dd7841cfdb04097 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 25 Aug 2026 12:07:03 -0700 Subject: [PATCH 05/16] Simplify CI test plan routing Collapse the nightly alternate plan into the shared event routing while preserving every GitHub output and schedule behavior. Strengthen the nightly characterization for all deterministic suites.\n\nAI assistance: prepared by OpenAI Codex under maintainer pbakaus's standing scheduled-refactor authorization. --- scripts/ci-test-plan.mjs | 36 ++++++++++++------------------------ tests/ci-test-plan.test.mjs | 2 ++ 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index bc06cdf7a..39764af77 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -12,32 +12,20 @@ const localNoChanges = !eventName && !process.env.CI_CHANGED_FILES; // (skill-behavior, accept-cleanup, deepseek), every single night. const isSchedule = eventName === 'schedule'; const changedFiles = localNoChanges || isSchedule ? [] : getChangedFiles(); -const forceDeterministic = localNoChanges || eventName === 'push' || eventName === 'workflow_dispatch'; +const forceDeterministic = localNoChanges || isSchedule || eventName === 'push' || eventName === 'workflow_dispatch'; const forceOptIn = eventName === 'workflow_dispatch'; -const plan = isSchedule - ? { - core: true, - detector: true, - live: true, - framework: true, - cli_remote_e2e: false, - live_e2e: true, - live_e2e_accept_cleanup: false, - skill_behavior: false, - live_svelte_adapter_deepseek: false, - } - : { - core: true, - detector: forceDeterministic || matchesSuiteTriggers('detector', changedFiles), - live: forceDeterministic || matchesSuiteTriggers('live', changedFiles), - framework: forceDeterministic || matchesSuiteTriggers('framework', changedFiles), - cli_remote_e2e: forceOptIn, - live_e2e: forceOptIn || matchesSuiteTriggers('live-e2e', changedFiles), - live_e2e_accept_cleanup: forceOptIn || matchesSuiteTriggers('live-e2e-accept-cleanup', changedFiles), - skill_behavior: forceOptIn || matchesSuiteTriggers('skill-behavior', changedFiles), - live_svelte_adapter_deepseek: forceOptIn || matchesSuiteTriggers('live-svelte-adapter-deepseek', changedFiles), - }; +const plan = { + core: true, + detector: forceDeterministic || matchesSuiteTriggers('detector', changedFiles), + live: forceDeterministic || matchesSuiteTriggers('live', changedFiles), + framework: forceDeterministic || matchesSuiteTriggers('framework', changedFiles), + cli_remote_e2e: forceOptIn, + live_e2e: isSchedule || forceOptIn || matchesSuiteTriggers('live-e2e', changedFiles), + live_e2e_accept_cleanup: forceOptIn || matchesSuiteTriggers('live-e2e-accept-cleanup', changedFiles), + skill_behavior: forceOptIn || matchesSuiteTriggers('skill-behavior', changedFiles), + live_svelte_adapter_deepseek: forceOptIn || matchesSuiteTriggers('live-svelte-adapter-deepseek', changedFiles), +}; writeGithubOutputs(plan); printSummary(plan, changedFiles); diff --git a/tests/ci-test-plan.test.mjs b/tests/ci-test-plan.test.mjs index d8b33fe72..2a9c16124 100644 --- a/tests/ci-test-plan.test.mjs +++ b/tests/ci-test-plan.test.mjs @@ -103,7 +103,9 @@ describe('ci-test-plan', () => { assert.equal(outputs.live_svelte_adapter_deepseek, 'false'); assert.equal(outputs.cli_remote_e2e, 'false'); assert.equal(outputs.core, 'true'); + assert.equal(outputs.detector, 'true'); assert.equal(outputs.live, 'true'); + assert.equal(outputs.framework, 'true'); }); }); From 08b03e876332937b180876051e2a5a338b1a87b0 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 21 Aug 2026 12:23:55 -0700 Subject: [PATCH 06/16] Centralize live path glob matching AI-assisted change prepared by Codex under scheduled architecture-simplification authorization from maintainer pbakaus. --- skill/scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++++++++ skill/scripts/live-inject.mjs | 44 ++------------------------- skill/scripts/live.mjs | 35 ++------------------- tests/live-inject.test.mjs | 28 +++++++++++++++++ 4 files changed, 69 insertions(+), 75 deletions(-) create mode 100644 skill/scripts/lib/live-path-globs.mjs diff --git a/skill/scripts/lib/live-path-globs.mjs b/skill/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/skill/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/skill/scripts/live-inject.mjs b/skill/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/skill/scripts/live-inject.mjs +++ b/skill/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/skill/scripts/live.mjs b/skill/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/skill/scripts/live.mjs +++ b/skill/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/tests/live-inject.test.mjs b/tests/live-inject.test.mjs index c58ba69f7..16a5ee4e9 100644 --- a/tests/live-inject.test.mjs +++ b/tests/live-inject.test.mjs @@ -10,10 +10,38 @@ import { dirname, join, relative, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; +import { livePathGlobToRegex } from '../skill/scripts/lib/live-path-globs.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const INJECT = resolve(__dirname, '..', 'skill/scripts/live-inject.mjs'); +describe('live path globs', () => { + it('matches recursive segments, including zero segments', () => { + const anywhere = livePathGlobToRegex('**/index.html'); + assert.equal(anywhere.test('index.html'), true); + assert.equal(anywhere.test('public/index.html'), true); + assert.equal(anywhere.test('apps/web/public/index.html'), true); + + const underPublic = livePathGlobToRegex('public/**/*.html'); + assert.equal(underPublic.test('public/index.html'), true); + assert.equal(underPublic.test('public/docs/index.html'), true); + assert.equal(underPublic.test('src/index.html'), false); + }); + + it('keeps single-star and question-mark matches inside one segment', () => { + const pattern = livePathGlobToRegex('pages/*/item?.html'); + assert.equal(pattern.test('pages/docs/item1.html'), true); + assert.equal(pattern.test('pages/docs/deep/item1.html'), false); + assert.equal(pattern.test('pages/docs/item12.html'), false); + }); + + it('treats regular-expression punctuation as literal path text', () => { + const pattern = livePathGlobToRegex('pages/[draft]/item+.html'); + assert.equal(pattern.test('pages/[draft]/item+.html'), true); + assert.equal(pattern.test('pages/d/itemm.html'), false); + }); +}); + function runInject(cwd, configPath, args) { try { const out = execFileSync('node', [INJECT, ...args], { From 0c2517884d4afd5c6856ff3dca7951e97cd3e11d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:02:05 +0000 Subject: [PATCH 07/16] Sync generated provider output --- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .agents/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .claude/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .cursor/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .gemini/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .github/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .grok/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .hermes/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .kiro/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .opencode/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .pi/skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .pi/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .qoder/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .rovodev/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .trae-cn/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .trae/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ .vibe/skills/impeccable/scripts/live.mjs | 35 +-------------- .../scripts/lib/live-path-globs.mjs | 37 ++++++++++++++++ .../skills/impeccable/scripts/live-inject.mjs | 44 +------------------ plugin/skills/impeccable/scripts/live.mjs | 35 +-------------- 48 files changed, 656 insertions(+), 1200 deletions(-) create mode 100644 .agents/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .claude/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .cursor/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .gemini/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .github/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .grok/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .hermes/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .kiro/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .opencode/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .pi/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .qoder/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .rovodev/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .trae/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 .vibe/skills/impeccable/scripts/lib/live-path-globs.mjs create mode 100644 plugin/skills/impeccable/scripts/lib/live-path-globs.mjs diff --git a/.agents/skills/impeccable/scripts/lib/live-path-globs.mjs b/.agents/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.agents/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.agents/skills/impeccable/scripts/live-inject.mjs b/.agents/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.agents/skills/impeccable/scripts/live-inject.mjs +++ b/.agents/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.agents/skills/impeccable/scripts/live.mjs b/.agents/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.agents/skills/impeccable/scripts/live.mjs +++ b/.agents/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.claude/skills/impeccable/scripts/lib/live-path-globs.mjs b/.claude/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.claude/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.claude/skills/impeccable/scripts/live-inject.mjs b/.claude/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.claude/skills/impeccable/scripts/live-inject.mjs +++ b/.claude/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.claude/skills/impeccable/scripts/live.mjs b/.claude/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.claude/skills/impeccable/scripts/live.mjs +++ b/.claude/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.cursor/skills/impeccable/scripts/lib/live-path-globs.mjs b/.cursor/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.cursor/skills/impeccable/scripts/live-inject.mjs b/.cursor/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.cursor/skills/impeccable/scripts/live-inject.mjs +++ b/.cursor/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.cursor/skills/impeccable/scripts/live.mjs b/.cursor/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.cursor/skills/impeccable/scripts/live.mjs +++ b/.cursor/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.gemini/skills/impeccable/scripts/lib/live-path-globs.mjs b/.gemini/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.gemini/skills/impeccable/scripts/live-inject.mjs b/.gemini/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.gemini/skills/impeccable/scripts/live-inject.mjs +++ b/.gemini/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.gemini/skills/impeccable/scripts/live.mjs b/.gemini/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.gemini/skills/impeccable/scripts/live.mjs +++ b/.gemini/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.github/skills/impeccable/scripts/lib/live-path-globs.mjs b/.github/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.github/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.github/skills/impeccable/scripts/live-inject.mjs b/.github/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.github/skills/impeccable/scripts/live-inject.mjs +++ b/.github/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.github/skills/impeccable/scripts/live.mjs b/.github/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.github/skills/impeccable/scripts/live.mjs +++ b/.github/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.grok/skills/impeccable/scripts/lib/live-path-globs.mjs b/.grok/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.grok/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.grok/skills/impeccable/scripts/live-inject.mjs b/.grok/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.grok/skills/impeccable/scripts/live-inject.mjs +++ b/.grok/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.grok/skills/impeccable/scripts/live.mjs b/.grok/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.grok/skills/impeccable/scripts/live.mjs +++ b/.grok/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.hermes/skills/impeccable/scripts/lib/live-path-globs.mjs b/.hermes/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.hermes/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.hermes/skills/impeccable/scripts/live-inject.mjs b/.hermes/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.hermes/skills/impeccable/scripts/live-inject.mjs +++ b/.hermes/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.hermes/skills/impeccable/scripts/live.mjs b/.hermes/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.hermes/skills/impeccable/scripts/live.mjs +++ b/.hermes/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.kiro/skills/impeccable/scripts/lib/live-path-globs.mjs b/.kiro/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.kiro/skills/impeccable/scripts/live-inject.mjs b/.kiro/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.kiro/skills/impeccable/scripts/live-inject.mjs +++ b/.kiro/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.kiro/skills/impeccable/scripts/live.mjs b/.kiro/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.kiro/skills/impeccable/scripts/live.mjs +++ b/.kiro/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.opencode/skills/impeccable/scripts/lib/live-path-globs.mjs b/.opencode/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.opencode/skills/impeccable/scripts/live-inject.mjs b/.opencode/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.opencode/skills/impeccable/scripts/live-inject.mjs +++ b/.opencode/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.opencode/skills/impeccable/scripts/live.mjs b/.opencode/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.opencode/skills/impeccable/scripts/live.mjs +++ b/.opencode/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.pi/skills/impeccable/scripts/lib/live-path-globs.mjs b/.pi/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.pi/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.pi/skills/impeccable/scripts/live-inject.mjs b/.pi/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.pi/skills/impeccable/scripts/live-inject.mjs +++ b/.pi/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.pi/skills/impeccable/scripts/live.mjs b/.pi/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.pi/skills/impeccable/scripts/live.mjs +++ b/.pi/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.qoder/skills/impeccable/scripts/lib/live-path-globs.mjs b/.qoder/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.qoder/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.qoder/skills/impeccable/scripts/live-inject.mjs b/.qoder/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.qoder/skills/impeccable/scripts/live-inject.mjs +++ b/.qoder/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.qoder/skills/impeccable/scripts/live.mjs b/.qoder/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.qoder/skills/impeccable/scripts/live.mjs +++ b/.qoder/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.rovodev/skills/impeccable/scripts/lib/live-path-globs.mjs b/.rovodev/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.rovodev/skills/impeccable/scripts/live-inject.mjs b/.rovodev/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.rovodev/skills/impeccable/scripts/live-inject.mjs +++ b/.rovodev/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.rovodev/skills/impeccable/scripts/live.mjs b/.rovodev/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.rovodev/skills/impeccable/scripts/live.mjs +++ b/.rovodev/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.trae-cn/skills/impeccable/scripts/lib/live-path-globs.mjs b/.trae-cn/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.trae-cn/skills/impeccable/scripts/live-inject.mjs b/.trae-cn/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.trae-cn/skills/impeccable/scripts/live-inject.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.trae-cn/skills/impeccable/scripts/live.mjs b/.trae-cn/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.trae-cn/skills/impeccable/scripts/live.mjs +++ b/.trae-cn/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.trae/skills/impeccable/scripts/lib/live-path-globs.mjs b/.trae/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.trae/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.trae/skills/impeccable/scripts/live-inject.mjs b/.trae/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.trae/skills/impeccable/scripts/live-inject.mjs +++ b/.trae/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.trae/skills/impeccable/scripts/live.mjs b/.trae/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.trae/skills/impeccable/scripts/live.mjs +++ b/.trae/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.vibe/skills/impeccable/scripts/lib/live-path-globs.mjs b/.vibe/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/.vibe/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/.vibe/skills/impeccable/scripts/live-inject.mjs b/.vibe/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/.vibe/skills/impeccable/scripts/live-inject.mjs +++ b/.vibe/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/.vibe/skills/impeccable/scripts/live.mjs b/.vibe/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/.vibe/skills/impeccable/scripts/live.mjs +++ b/.vibe/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/plugin/skills/impeccable/scripts/lib/live-path-globs.mjs b/plugin/skills/impeccable/scripts/lib/live-path-globs.mjs new file mode 100644 index 000000000..4a3eebda0 --- /dev/null +++ b/plugin/skills/impeccable/scripts/lib/live-path-globs.mjs @@ -0,0 +1,37 @@ +/** + * Convert a live-config glob pattern to a RegExp. + * + * Supports `**` across path segments, `*` within one segment, and `?` for one + * character. Callers normalize project-relative paths to forward slashes. + */ +export function livePathGlobToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += `\\${c}`; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp(`^${re}$`); +} diff --git a/plugin/skills/impeccable/scripts/live-inject.mjs b/plugin/skills/impeccable/scripts/live-inject.mjs index 81848010b..0b5fe9c18 100644 --- a/plugin/skills/impeccable/scripts/live-inject.mjs +++ b/plugin/skills/impeccable/scripts/live-inject.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { describeInjectArtifacts, frameworkIgnorePatterns, @@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) { const patterns = config.files; const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; - const excludeRegexes = allExcludes.map(globToRegex); + const excludeRegexes = allExcludes.map(livePathGlobToRegex); const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); const isGlob = (s) => /[*?[]/.test(s); @@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) { return out; } -/** - * Convert a glob pattern to a RegExp. Supports: - * ** → any number of path segments (including zero) - * * → any chars except `/` - * ? → any single char except `/` - * Paths are normalized to forward slashes before matching. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - // ** — any number of segments, including zero. Handle the common - // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. - if (pattern[i + 2] === '/') { - re += '(?:.*/)?'; - i += 3; - } else { - re += '.*'; - i += 2; - } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- diff --git a/plugin/skills/impeccable/scripts/live.mjs b/plugin/skills/impeccable/scripts/live.mjs index 7738c3f02..ccbef4949 100644 --- a/plugin/skills/impeccable/scripts/live.mjs +++ b/plugin/skills/impeccable/scripts/live.mjs @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'; import { resolveTargetSelection } from './context.mjs'; import { resolveFiles } from './live-inject.mjs'; import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; +import { livePathGlobToRegex } from './lib/live-path-globs.mjs'; import { resolveSurfaceBrief } from './lib/surface-briefs.mjs'; import { resolveLiveTarget } from './live-target.mjs'; import { bootInstructions } from './live/instructions.mjs'; @@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) { // Files matching the user's `exclude` globs are intentional omissions, // not drift. Compile them to regexes so the orphan list stays signal. const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) - .map((p) => globToRegex(p)); + .map(livePathGlobToRegex); const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); const orphans = []; @@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) { }; } -/** - * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here - * to avoid a circular import (live-inject.mjs already imports nothing - * from live.mjs). The two must stay in sync. - */ -function globToRegex(pattern) { - let re = ''; - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } - else { re += '.*'; i += 2; } - } else { - re += '[^/]*'; - i += 1; - } - } else if (c === '?') { - re += '[^/]'; - i += 1; - } else if (/[.+^${}()|[\]\\]/.test(c)) { - re += '\\' + c; - i += 1; - } else { - re += c; - i += 1; - } - } - return new RegExp('^' + re + '$'); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- From 77a2eae8613868c17a13da0c23762fc594102350 Mon Sep 17 00:00:00 2001 From: 0xDarkMatter <0xDarkMatter@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:56:03 +1000 Subject: [PATCH 08/16] Add IMPECCABLE_CACHE_ROOT to relocate hook state out of project roots (#422) Honor an optional IMPECCABLE_CACHE_ROOT env var in getCachePath() / getPendingPath(): when set, hook.cache.json and hook.pending.json land under $IMPECCABLE_CACHE_ROOT// (slug = project path with [:\/.] mapped to hyphens, mirroring Claude Code's ~/.claude/projects/ convention). Unset or blank env keeps stock project-local behavior. User-authored config (config.json, config.local.json, design.json) deliberately stays project-local - only disposable state relocates. Also clears ambient IMPECCABLE_CACHE_ROOT at the top of hook.test.mjs so a developer running the suite with the redirect active still gets deterministic stock-path assertions; the new suite sets and restores the var explicitly. Prepared with AI assistance (Claude Code) under direction of 0xDarkMatter, per the maintainer-approved issue #422. Co-Authored-By: Claude Fable 5 --- skill/scripts/hook-lib.mjs | 21 ++++++++++-- tests/hook.test.mjs | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index 1e709b11a..4f9c740f2 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -210,12 +210,29 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +function hookStateDir(cwd) { + const root = process.env.IMPECCABLE_CACHE_ROOT; + if (root && typeof root === 'string' && root.trim()) { + const slug = String(cwd).replace(/[:\\/.]/g, '-'); + return path.join(root, slug); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index e11d3fb62..9de8b6930 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -24,6 +24,8 @@ import { truthy, getConfigPath, getLocalConfigPath, + getCachePath, + getPendingPath, ensureHookGitExcludes, readConfig, readCache, @@ -67,6 +69,12 @@ import { import { normalizeIgnoreValueEntries as normalizeIgnoreValueEntriesCli } from '../cli/lib/impeccable-config.mjs'; import { detectHtml, detectText } from '../cli/engine/detect-antipatterns.mjs'; +// Hook state paths are env-sensitive: an ambient IMPECCABLE_CACHE_ROOT (a +// developer using the redirect locally) would relocate cache/pending out of +// the tmp projects and break stock-path assertions. Clear it up front; the +// dedicated issue-#422 suite sets and restores it explicitly. +delete process.env.IMPECCABLE_CACHE_ROOT; + function mkTmp() { return fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-hook-')); } @@ -415,6 +423,68 @@ describe('readCache / persistCache / bumpEditCount', () => { }); }); +describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => { + let cwd; + let cacheRoot; + let savedEnv; + beforeEach(() => { + cwd = mkTmp(); + cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cache-root-')); + savedEnv = process.env.IMPECCABLE_CACHE_ROOT; + }); + afterEach(() => { + if (savedEnv === undefined) delete process.env.IMPECCABLE_CACHE_ROOT; + else process.env.IMPECCABLE_CACHE_ROOT = savedEnv; + fs.rmSync(cwd, { recursive: true, force: true }); + fs.rmSync(cacheRoot, { recursive: true, force: true }); + }); + + it('keeps hook state project-local when the env var is unset', () => { + delete process.env.IMPECCABLE_CACHE_ROOT; + assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json')); + assert.equal(getPendingPath(cwd), path.join(cwd, '.impeccable', 'hook.pending.json')); + }); + + it('treats a blank env var as unset', () => { + process.env.IMPECCABLE_CACHE_ROOT = ' '; + assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json')); + }); + + it('relocates cache and pending under a per-project slug dir', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const slug = String(cwd).replace(/[:\\/.]/g, '-'); + assert.equal(getCachePath(cwd), path.join(cacheRoot, slug, 'hook.cache.json')); + assert.equal(getPendingPath(cwd), path.join(cacheRoot, slug, 'hook.pending.json')); + }); + + it('slug maps colons, slashes, backslashes, and dots to hyphens', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const cachePath = getCachePath('C:\\work\\my.app/sub'); + const slugDir = path.basename(path.dirname(cachePath)); + assert.equal(slugDir, 'C--work-my-app-sub'); + }); + + it('config paths stay project-local even when the redirect is active', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + assert.equal(getConfigPath(cwd), path.join(cwd, '.impeccable', 'config.json')); + assert.equal(getLocalConfigPath(cwd), path.join(cwd, '.impeccable', 'config.local.json')); + }); + + it('persistCache round-trips through the redirect dir and leaves the project root clean', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const cache = readCache(cwd); + bumpEditCount(cache, 'sid-1', '/x/a.tsx'); + assert.equal(persistCache(cwd, cache), true); + + assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'project root untouched'); + const slug = String(cwd).replace(/[:\\/.]/g, '-'); + assert.equal(fs.existsSync(path.join(cacheRoot, slug, 'hook.cache.json')), true); + + const reloaded = readCache(cwd); + assert.equal(reloaded.sessions['sid-1'].files['/x/a.tsx'].editCount, 1); + }); +}); + describe('ensureHookGitExcludes()', () => { let cwd; beforeEach(() => { cwd = mkTmp(); }); From 5c82d58b7e89cde2613c2e043d51094a83269b44 Mon Sep 17 00:00:00 2001 From: 0xDarkMatter <0xDarkMatter@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:04:41 +1000 Subject: [PATCH 09/16] Harden IMPECCABLE_CACHE_ROOT edges: normalization, opt-in gate, failure path - hookStateDir now trims the env value (stray whitespace in env files) and path.resolve()s both the root and the cwd, so trailing separators and relative segments slug to the same per-project dir. - The #344/#305 persist gate also treats an existing (possibly redirected) cache file as the opted-in marker. Without this, once state relocated, clean-edit editCount bumps stopped persisting because the project-local .impeccable/ dir never appears. No-op under stock paths, where the cache file lives inside .impeccable/. - New tests: slug normalization equivalences, whitespace trim, graceful persistCache failure on an unusable root, and three runHook end-to-end cases (findings persist + dedup through the redirect, clean-edit editCount persistence, and the no-footprint no-op gate holding under redirect). Prepared with AI assistance (Claude Code) under direction of 0xDarkMatter, per the maintainer-approved issue #422. Co-Authored-By: Claude Fable 5 --- skill/scripts/hook-lib.mjs | 23 +++++--- tests/hook.test.mjs | 106 +++++++++++++++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index 4f9c740f2..fa5f2f1ef 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -218,11 +218,17 @@ export function getLocalConfigPath(cwd) { // artifacts (issue #422). User-authored config (config.json, // config.local.json, design.json) deliberately stays project-local — only // disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; resolving both +// sides makes the slug deterministic when callers hand in a trailing +// separator or unnormalized cwd. function hookStateDir(cwd) { - const root = process.env.IMPECCABLE_CACHE_ROOT; - if (root && typeof root === 'string' && root.trim()) { - const slug = String(cwd).replace(/[:\\/.]/g, '-'); - return path.join(root, slug); + const raw = process.env.IMPECCABLE_CACHE_ROOT; + const root = typeof raw === 'string' ? raw.trim() : ''; + if (root) { + const slug = path.resolve(String(cwd)).replace(/[:\\/.]/g, '-'); + return path.join(path.resolve(root), slug); } return path.join(cwd, '.impeccable'); } @@ -2139,8 +2145,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 9de8b6930..915ac063e 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -457,11 +457,36 @@ describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => { assert.equal(getPendingPath(cwd), path.join(cacheRoot, slug, 'hook.pending.json')); }); - it('slug maps colons, slashes, backslashes, and dots to hyphens', () => { + it('slug maps separators, colons, and dots to hyphens', () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; - const cachePath = getCachePath('C:\\work\\my.app/sub'); - const slugDir = path.basename(path.dirname(cachePath)); - assert.equal(slugDir, 'C--work-my-app-sub'); + const proj = path.join(cwd, 'my.app', 'v2'); + const slugDir = path.basename(path.dirname(getCachePath(proj))); + assert.doesNotMatch(slugDir, /[:\\/.]/, 'no path-significant chars survive'); + assert.ok(slugDir.endsWith('my-app-v2'), `dots and separators map to hyphens (got ${slugDir})`); + }); + + it('trailing separators and relative segments slug to the same dir', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const canonical = getCachePath(cwd); + assert.equal(getCachePath(cwd + path.sep), canonical); + assert.equal(getCachePath(path.join(cwd, 'sub', '..')), canonical); + }); + + it('trims stray whitespace from the env value', () => { + process.env.IMPECCABLE_CACHE_ROOT = ` ${cacheRoot} `; + const slug = path.resolve(cwd).replace(/[:\\/.]/g, '-'); + assert.equal(getCachePath(cwd), path.join(cacheRoot, slug, 'hook.cache.json')); + }); + + it('persistCache degrades gracefully when the cache root is unusable', () => { + // Point the root at an existing FILE so mkdir of the slug dir must fail. + const blocker = path.join(cacheRoot, 'not-a-dir'); + fs.writeFileSync(blocker, 'x'); + process.env.IMPECCABLE_CACHE_ROOT = blocker; + const cache = readCache(cwd); + bumpEditCount(cache, 'sid-1', '/x/a.tsx'); + assert.equal(persistCache(cwd, cache), false, 'returns false instead of throwing'); + assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false); }); it('config paths stay project-local even when the redirect is active', () => { @@ -483,6 +508,79 @@ describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => { const reloaded = readCache(cwd); assert.equal(reloaded.sessions['sid-1'].files['/x/a.tsx'].editCount, 1); }); + + function redirectEventFor(file, sessionId = 'redir-sid') { + return { + session_id: sessionId, + cwd, + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: file }, + }; + } + + function writeProjectFile(rel, body) { + const abs = path.join(cwd, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body); + return abs; + } + + it('runHook end-to-end: findings persist under the redirect root, project root stays clean', async () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const file = writeProjectFile('src/Card.tsx', 'noop'); + const det = fakeDetector([finding('text-overflow', 1)]); + + const first = await runHook({ + stdinJson: JSON.stringify(redirectEventFor(file)), + env: {}, cwd, detector: det, + }); + assert.match(first.stdout, /Design hook findings requiring review/); + assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'no project-local footprint'); + assert.equal(fs.existsSync(getCachePath(cwd)), true, 'cache lands under the redirect root'); + + // Session dedup still works across runs through the redirected cache. + const second = await runHook({ + stdinJson: JSON.stringify(redirectEventFor(file)), + env: {}, cwd, detector: det, + }); + assert.doesNotMatch(second.stdout, /Design hook findings requiring review/); + assert.match(second.stdout, /flagged earlier this session/); + }); + + it('runHook end-to-end: clean edits keep persisting editCount once redirected state exists', async () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const file = writeProjectFile('src/Card.tsx', 'noop'); + + // Earn the footprint (in the redirect dir) with a real finding first. + await runHook({ + stdinJson: JSON.stringify(redirectEventFor(file)), + env: {}, cwd, detector: fakeDetector([finding('text-overflow', 1)]), + }); + assert.equal(fs.existsSync(getCachePath(cwd)), true); + + // A clean follow-up edit must still persist its editCount bump — the + // opted-in check has to see the redirected cache, not just `/.impeccable/`. + await runHook({ + stdinJson: JSON.stringify(redirectEventFor(file)), + env: {}, cwd, detector: fakeDetector([]), + }); + const cache = readCache(cwd); + assert.equal(cache.sessions['redir-sid'].files[file].editCount, 2); + assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'project root still clean'); + }); + + it('runHook end-to-end: a no-footprint clean edit writes nothing anywhere (gates hold under redirect)', async () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const file = writeProjectFile('src/Card.tsx', 'noop'); + const r = await runHook({ + stdinJson: JSON.stringify(redirectEventFor(file)), + env: {}, cwd, detector: fakeDetector([]), + }); + assert.match(r.stdout, /No deterministic design-quality issues found/); + assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false); + assert.equal(fs.existsSync(getCachePath(cwd)), false, 'redirect root also stays empty'); + }); }); describe('ensureHookGitExcludes()', () => { From 30b3628f5bafe7c0d9b126b66126c94bf6742c0e Mon Sep 17 00:00:00 2001 From: 0xDarkMatter <0xDarkMatter@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:08:25 +1000 Subject: [PATCH 10/16] Expand a leading ~ in IMPECCABLE_CACHE_ROOT against the home dir Env files and settings JSON hand '~/caches' to Node unexpanded; without this it would resolve to a literal '~' directory under the process cwd. Mirrors the exact treatment IMPECCABLE_HOOK_LOG already gets in writeAuditLog (HOME || USERPROFILE fallback), plus the Windows '~\' spelling. Prepared with AI assistance (Claude Code) under direction of 0xDarkMatter, per the maintainer-approved issue #422. Co-Authored-By: Claude Fable 5 --- skill/scripts/hook-lib.mjs | 13 +++++++++---- tests/hook.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index fa5f2f1ef..91d3de7d9 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -220,12 +220,17 @@ export function getLocalConfigPath(cwd) { // disposable state relocates. // Read from process.env (not runHook's injected env): the cache root is a // machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation -// switch. Trim guards against stray whitespace in env files; resolving both -// sides makes the slug deterministic when callers hand in a trailing -// separator or unnormalized cwd. +// switch. Trim guards against stray whitespace in env files; `~/` expands to +// the home dir (settings/env files hand it to Node unexpanded — same +// treatment IMPECCABLE_HOOK_LOG gets in writeAuditLog); resolving both sides +// makes the slug deterministic when callers hand in a trailing separator or +// unnormalized cwd. function hookStateDir(cwd) { const raw = process.env.IMPECCABLE_CACHE_ROOT; - const root = typeof raw === 'string' ? raw.trim() : ''; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + root = path.join(process.env.HOME || process.env.USERPROFILE || '.', root.slice(2)); + } if (root) { const slug = path.resolve(String(cwd)).replace(/[:\\/.]/g, '-'); return path.join(path.resolve(root), slug); diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 915ac063e..03aaf617c 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -495,6 +495,26 @@ describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => { assert.equal(getLocalConfigPath(cwd), path.join(cwd, '.impeccable', 'config.local.json')); }); + it('expands a leading ~/ against the home dir, like IMPECCABLE_HOOK_LOG', () => { + const savedHome = process.env.HOME; + const savedProfile = process.env.USERPROFILE; + try { + process.env.HOME = cacheRoot; + delete process.env.USERPROFILE; + process.env.IMPECCABLE_CACHE_ROOT = '~/impeccable-state'; + const slug = path.resolve(cwd).replace(/[:\\/.]/g, '-'); + assert.equal( + getCachePath(cwd), + path.join(cacheRoot, 'impeccable-state', slug, 'hook.cache.json'), + ); + } finally { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedProfile; + } + }); + it('persistCache round-trips through the redirect dir and leaves the project root clean', () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; const cache = readCache(cwd); From cbd7870159100f98c7cbd8803fdcc878b93058db Mon Sep 17 00:00:00 2001 From: 0xDarkMatter <0xDarkMatter@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:19:21 +1000 Subject: [PATCH 11/16] Address review: collision-resistant slugs, os.homedir() tilde expansion - The per-project state dir key is now the readable separator-mapped slug plus an 8-hex sha256 of the resolved project path. The readable part alone is lossy (/x/my.app and /x/my-app both mapped to -x-my-app and shared hook state); the digest keeps distinct projects' cache and pending state apart while the dir name stays human-scannable. - Tilde roots now expand via os.homedir() instead of HOME/USERPROFILE with a '.' fallback. When no home dir can be determined, expansion is rejected and state falls back to the project-local default rather than anchoring under the hook process's working directory. - Tests updated to the digest-suffixed slug via a mirrored slugFor() helper, plus two new cases: colliding readable slugs get distinct state dirs, and the tilde form resolves identically to the explicit homedir-joined form. Prepared with AI assistance (Claude Code) under direction of 0xDarkMatter, per the maintainer-approved issue #422. Co-Authored-By: Claude Fable 5 --- skill/scripts/hook-lib.mjs | 26 +++++++++++----- tests/hook.test.mjs | 64 ++++++++++++++++++++++---------------- 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index 91d3de7d9..767fe65a6 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -220,20 +221,29 @@ export function getLocalConfigPath(cwd) { // disposable state relocates. // Read from process.env (not runHook's injected env): the cache root is a // machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation -// switch. Trim guards against stray whitespace in env files; `~/` expands to -// the home dir (settings/env files hand it to Node unexpanded — same -// treatment IMPECCABLE_HOOK_LOG gets in writeAuditLog); resolving both sides -// makes the slug deterministic when callers hand in a trailing separator or -// unnormalized cwd. +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. function hookStateDir(cwd) { const raw = process.env.IMPECCABLE_CACHE_ROOT; let root = typeof raw === 'string' ? raw.trim() : ''; if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { - root = path.join(process.env.HOME || process.env.USERPROFILE || '.', root.slice(2)); + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; } if (root) { - const slug = path.resolve(String(cwd)).replace(/[:\\/.]/g, '-'); - return path.join(path.resolve(root), slug); + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); } return path.join(cwd, '.impeccable'); } diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 03aaf617c..4719573eb 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -9,6 +9,7 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; @@ -450,19 +451,40 @@ describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => { assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json')); }); + // Mirrors hookStateDir's slug formula: readable separator-mapped path plus + // an 8-hex sha256 disambiguator. + function slugFor(p) { + const resolved = path.resolve(p); + const readable = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return `${readable}-${digest}`; + } + it('relocates cache and pending under a per-project slug dir', () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; - const slug = String(cwd).replace(/[:\\/.]/g, '-'); - assert.equal(getCachePath(cwd), path.join(cacheRoot, slug, 'hook.cache.json')); - assert.equal(getPendingPath(cwd), path.join(cacheRoot, slug, 'hook.pending.json')); + assert.equal(getCachePath(cwd), path.join(cacheRoot, slugFor(cwd), 'hook.cache.json')); + assert.equal(getPendingPath(cwd), path.join(cacheRoot, slugFor(cwd), 'hook.pending.json')); }); - it('slug maps separators, colons, and dots to hyphens', () => { + it('slug maps separators, colons, and dots to hyphens, with a digest suffix', () => { process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; const proj = path.join(cwd, 'my.app', 'v2'); const slugDir = path.basename(path.dirname(getCachePath(proj))); assert.doesNotMatch(slugDir, /[:\\/.]/, 'no path-significant chars survive'); - assert.ok(slugDir.endsWith('my-app-v2'), `dots and separators map to hyphens (got ${slugDir})`); + assert.match(slugDir, /my-app-v2-[0-9a-f]{8}$/, `readable slug + 8-hex digest (got ${slugDir})`); + }); + + it('distinct projects whose readable slugs collide get distinct state dirs', () => { + process.env.IMPECCABLE_CACHE_ROOT = cacheRoot; + const dotted = path.join(cwd, 'my.app'); + const dashed = path.join(cwd, 'my-app'); + // Readable part is identical for both... + assert.equal( + path.resolve(dotted).replace(/[:\\/.]/g, '-'), + path.resolve(dashed).replace(/[:\\/.]/g, '-'), + ); + // ...but the digest keeps their hook state apart. + assert.notEqual(path.dirname(getCachePath(dotted)), path.dirname(getCachePath(dashed))); }); it('trailing separators and relative segments slug to the same dir', () => { @@ -474,8 +496,7 @@ describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => { it('trims stray whitespace from the env value', () => { process.env.IMPECCABLE_CACHE_ROOT = ` ${cacheRoot} `; - const slug = path.resolve(cwd).replace(/[:\\/.]/g, '-'); - assert.equal(getCachePath(cwd), path.join(cacheRoot, slug, 'hook.cache.json')); + assert.equal(getCachePath(cwd), path.join(cacheRoot, slugFor(cwd), 'hook.cache.json')); }); it('persistCache degrades gracefully when the cache root is unusable', () => { @@ -495,24 +516,14 @@ describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => { assert.equal(getLocalConfigPath(cwd), path.join(cwd, '.impeccable', 'config.local.json')); }); - it('expands a leading ~/ against the home dir, like IMPECCABLE_HOOK_LOG', () => { - const savedHome = process.env.HOME; - const savedProfile = process.env.USERPROFILE; - try { - process.env.HOME = cacheRoot; - delete process.env.USERPROFILE; - process.env.IMPECCABLE_CACHE_ROOT = '~/impeccable-state'; - const slug = path.resolve(cwd).replace(/[:\\/.]/g, '-'); - assert.equal( - getCachePath(cwd), - path.join(cacheRoot, 'impeccable-state', slug, 'hook.cache.json'), - ); - } finally { - if (savedHome === undefined) delete process.env.HOME; - else process.env.HOME = savedHome; - if (savedProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = savedProfile; - } + it('expands a leading ~/ against os.homedir()', () => { + // Property check without duplicating the expansion: the tilde form must + // resolve identically to the explicit homedir-joined form. + process.env.IMPECCABLE_CACHE_ROOT = path.join(os.homedir(), 'impeccable-state'); + const explicit = getCachePath(cwd); + process.env.IMPECCABLE_CACHE_ROOT = '~/impeccable-state'; + assert.equal(getCachePath(cwd), explicit); + assert.ok(explicit.startsWith(os.homedir()), 'anchored under the home dir'); }); it('persistCache round-trips through the redirect dir and leaves the project root clean', () => { @@ -522,8 +533,7 @@ describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => { assert.equal(persistCache(cwd, cache), true); assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'project root untouched'); - const slug = String(cwd).replace(/[:\\/.]/g, '-'); - assert.equal(fs.existsSync(path.join(cacheRoot, slug, 'hook.cache.json')), true); + assert.equal(fs.existsSync(path.join(cacheRoot, slugFor(cwd), 'hook.cache.json')), true); const reloaded = readCache(cwd); assert.equal(reloaded.sessions['sid-1'].files['/x/a.tsx'].editCount, 1); From 09506a9bb5bd4fc87e696718f0f9c8ba2b60692c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:15:23 +0000 Subject: [PATCH 12/16] Sync generated provider output --- .../skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .../skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .../skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .../skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .../skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .grok/skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .../skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .kiro/skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .../skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .pi/skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .qoder/skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .../skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .../skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .trae/skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- .vibe/skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- plugin/skills/impeccable/scripts/hook-lib.mjs | 51 +++++++++++++++++-- 16 files changed, 752 insertions(+), 64 deletions(-) diff --git a/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.grok/skills/impeccable/scripts/hook-lib.mjs b/.grok/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.grok/skills/impeccable/scripts/hook-lib.mjs +++ b/.grok/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.hermes/skills/impeccable/scripts/hook-lib.mjs b/.hermes/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.hermes/skills/impeccable/scripts/hook-lib.mjs +++ b/.hermes/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/.vibe/skills/impeccable/scripts/hook-lib.mjs b/.vibe/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/.vibe/skills/impeccable/scripts/hook-lib.mjs +++ b/.vibe/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } diff --git a/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index 1e709b11a..767fe65a6 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/scripts/hook-lib.mjs @@ -43,6 +43,7 @@ * `cli/engine/detect-antipatterns.mjs` (running from source). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) { return path.join(cwd, '.impeccable', 'config.local.json'); } +// Where mutable hook state (cache + pending) lives. Defaults to the +// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state +// relocates to a per-project subdirectory of that root instead, keyed by a +// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's +// `~/.claude/projects/` convention), so project roots stay free of tool +// artifacts (issue #422). User-authored config (config.json, +// config.local.json, design.json) deliberately stays project-local — only +// disposable state relocates. +// Read from process.env (not runHook's injected env): the cache root is a +// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation +// switch. Trim guards against stray whitespace in env files; `~/` (or the +// Windows `~\` spelling) expands via os.homedir(), and when no home dir can +// be determined the expansion is rejected — state falls back to the +// project-local default rather than anchoring under the hook process's cwd. +// Resolving both sides makes the slug deterministic when callers hand in a +// trailing separator or unnormalized cwd. The slug is the readable +// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the +// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map +// to `-x-my-app` and share state), so the digest disambiguates while keeping +// the dir name human-scannable. +function hookStateDir(cwd) { + const raw = process.env.IMPECCABLE_CACHE_ROOT; + let root = typeof raw === 'string' ? raw.trim() : ''; + if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') { + let home = ''; + try { home = os.homedir() || ''; } catch { home = ''; } + root = home ? path.join(home, root.slice(2)) : ''; + } + if (root) { + const resolved = path.resolve(String(cwd)); + const slug = resolved.replace(/[:\\/.]/g, '-'); + const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8); + return path.join(path.resolve(root), `${slug}-${digest}`); + } + return path.join(cwd, '.impeccable'); +} + export function getCachePath(cwd) { - return path.join(cwd, '.impeccable', 'hook.cache.json'); + return path.join(hookStateDir(cwd), 'hook.cache.json'); } export function getPendingPath(cwd) { - return path.join(cwd, '.impeccable', 'hook.pending.json'); + return path.join(hookStateDir(cwd), 'hook.pending.json'); } export function resolveProjectCwd(event, fallback = process.cwd()) { @@ -2122,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = // touched-file list for the Stop deep pass, and an already-present // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a // clean UI edit in a project with no Impeccable footprint, must be a - // no-op on disk (issues #344, #305). - if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + // no-op on disk (issues #344, #305). An existing cache file also counts + // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives + // outside the project, so the project dir alone can't carry the marker — + // without this, clean-edit editCount bumps would stop persisting the + // moment state relocates. Under stock paths the cache sits inside + // `.impeccable/`, so the extra check changes nothing there. + if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) { persistCache(projectCwd, cache); } From 152d6940b04db9a7356a5b6a76ec5e00200027d5 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Fri, 28 Aug 2026 09:08:42 +0500 Subject: [PATCH 13/16] Fix: harden live overlay detector waivers (#639 follow-up) Read waiver config from every live root (appRoot, contextRoot, repoRoot), so monorepo projects whose config lives at the repo root reach the overlay; serialize served roots and page identities repo-relative there. Resolve each page URL to its actual serving file via the inject config's resolved page list before applying file-scoped waivers; ambiguous URLs keep the conservative common-ancestor fallback (PR #645 review discussion r3840011436). Honour detector.ignoreFiles: a wholly waived page now scans to zero findings in the overlay, matching the CLI and the edit hook. Guard the resolver call so a throwing resolver degrades to an unfiltered scan instead of breaking the detect toggle. Match design-system-color waivers by color value across hex and rgb() spellings, and stop extracting font values for bounce-easing findings, mirroring extractFindingIgnoreValue. Regenerate the browser bundle. AI-assisted change: reviewed, planned, and implemented with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 --- cli/engine/browser/injected/index.mjs | 70 +++++++++- cli/engine/detect-antipatterns-browser.js | 70 +++++++++- scripts/test-suites.mjs | 1 + skill/scripts/live-browser-ignores.js | 65 +++++++-- skill/scripts/live-browser.js | 32 +++-- skill/scripts/live-server.mjs | 60 ++------- skill/scripts/live/project-ignores.mjs | 139 ++++++++++++++++++++ tests/detect-antipatterns-browser.test.mjs | 40 +++++- tests/live-browser-ignores.test.mjs | 98 +++++++++++++- tests/live-browser-regression.test.mjs | 9 +- tests/live-browser-script-parts.test.mjs | 4 +- tests/live-project-ignores.test.mjs | 145 +++++++++++++++++++++ 12 files changed, 643 insertions(+), 90 deletions(-) create mode 100644 skill/scripts/live/project-ignores.mjs create mode 100644 tests/live-project-ignores.test.mjs diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index ed91c8fb1..0d0c26a16 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1473,6 +1473,14 @@ if (IS_BROWSER) { } function collectBrowserFindings() { + // A page matched by detector.ignoreFiles is waived wholesale: answer the + // scan with the empty shape 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. + if (EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true) { + 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 +1710,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 +1735,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)); diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index f40a768f7..281e49810 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -8132,6 +8132,14 @@ if (IS_BROWSER) { } function collectBrowserFindings() { + // A page matched by detector.ignoreFiles is waived wholesale: answer the + // scan with the empty shape 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. + if (EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true) { + 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 +8369,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 +8394,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)); diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 5d2d1f227..5be924d3e 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -153,6 +153,7 @@ export const SUITES = { 'tests/live-insert-ui.test.mjs', 'tests/live-manual-edits-buffer.test.mjs', 'tests/live-poll.test.mjs', + 'tests/live-project-ignores.test.mjs', 'tests/live-poll-lanes.test.mjs', 'tests/live-poll-stream.test.mjs', 'tests/live-recovery-commands.test.mjs', diff --git a/skill/scripts/live-browser-ignores.js b/skill/scripts/live-browser-ignores.js index 92df1132b..1c8514c7b 100644 --- a/skill/scripts/live-browser-ignores.js +++ b/skill/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/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index e9435f8c2..2b38ec62e 100644 --- a/skill/scripts/live-browser.js +++ b/skill/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/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index ab298843b..ebdb8f4d8 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/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/skill/scripts/live/project-ignores.mjs b/skill/scripts/live/project-ignores.mjs new file mode 100644 index 000000000..e99acaed1 --- /dev/null +++ b/skill/scripts/live/project-ignores.mjs @@ -0,0 +1,139 @@ +/** + * Project detector waivers for the live overlay (issue #639, hardened in the + * PR #645 follow-up). One place decides what the /live.js prelude serializes + * as window.__IMPECCABLE_PROJECT_IGNORES__: + * + * ignoreRules detector.ignoreRules, unioned across every live root. + * ignoreValues detector.ignoreValues entries ({rule, value, files?}), + * deduped across roots; createdAt/reason stay local. + * ignoreFiles detector.ignoreFiles globs, unioned across roots, so a + * wholly waived page scans to zero findings in the overlay + * just as it reports nothing through the CLI and the hook. + * roots served-root prefixes derived from the inject config's own + * `files` globs. Never derived from the ignore globs: one + * entry scoped to prototype/library/** would lend + * prototype/library/ as a candidate prefix to every page, + * and that rule would suppress site-wide (issue #639). + * pageFiles the inject config's `files` expanded to real project + * files, so the browser can resolve a URL to the one file it + * actually serves instead of trying every root (PR #645 + * review: with src/ and public/ both served, /foo.html must + * not borrow src/foo.html's waivers while actually serving + * public/foo.html). + * + * Config is read from every root the live session spans: the appRoot the + * server chdir'd onto, plus contextRoot and repoRoot when they differ. The + * edit hook keys the same config at the session cwd (the repo root in a + * monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its + * invocation cwd, so reading only the appRoot silently dropped every waiver + * in exactly the monorepo layouts the roots manifest exists for. Reading is + * additive across roots, matching readConfig's own union of config.json and + * config.local.json. + * + * In a monorepo, roots and pageFiles are serialized repo-relative (the + * appRoot's path inside the repo is prefixed), so waivers spelled from + * either root match through the resolver's suffix expansion. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { readConfig } from '../hook-lib.mjs'; +import { resolveFiles } from '../live-inject.mjs'; +import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs'; + +// Serializing thousands of page identities into every /live.js response +// helps nobody; past this cap pageFiles is omitted and the resolver falls +// back to the served-root common ancestor, which is correct, just less +// precise about cross-root duplicates. +const PAGE_FILES_CAP = 500; + +export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) { + const configRoots = []; + for (const dir of [appRoot, contextRoot, repoRoot]) { + if (typeof dir !== 'string' || !dir) continue; + const resolved = path.resolve(dir); + if (!configRoots.includes(resolved)) configRoots.push(resolved); + } + if (configRoots.length === 0) configRoots.push(process.cwd()); + + const ignoreRules = new Set(); + const ignoreFiles = new Set(); + const valueEntries = new Map(); + for (const dir of configRoots) { + // readConfig merges config.json with the gitignored config.local.json + // and type-checks both, exactly as the edit hook reads the same pair. + const config = readConfig(dir); + for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) { + if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule); + } + for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) { + if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob); + } + for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) { + if (!entry || typeof entry !== 'object') continue; + // readConfig already normalized rule/value and folded `file` into + // `files`; serve only what the browser matches on. + const serialized = { + rule: entry.rule, + value: entry.value, + ...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}), + }; + const key = JSON.stringify([serialized.rule, serialized.value, + Array.isArray(serialized.files) ? [...serialized.files].sort() : []]); + if (!valueEntries.has(key)) valueEntries.set(key, serialized); + } + } + + const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir }); + return { + ignoreRules: [...ignoreRules], + ignoreValues: [...valueEntries.values()], + ignoreFiles: [...ignoreFiles], + roots: served.roots, + pageFiles: served.pageFiles, + }; +} + +function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) { + let live = null; + try { + const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir }); + live = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // No readable inject config: the browser matches URL paths as-is. + return { roots: [], pageFiles: [] }; + } + const files = Array.isArray(live?.files) + ? live.files.filter((glob) => typeof glob === 'string' && glob) + : []; + + // A monorepo appRoot serializes identities repo-relative, so waivers + // spelled from either root match through the resolver's suffix expansion. + let prefix = ''; + if (typeof repoRoot === 'string' && repoRoot) { + const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/'); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`; + } + + const roots = [...new Set(files.map((glob) => { + const wildcardAt = glob.search(/[*?{]/); + const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt); + const cut = head.lastIndexOf('/'); + return prefix + (cut > -1 ? head.slice(0, cut + 1) : ''); + }))]; + + let pageFiles = []; + try { + pageFiles = resolveFiles(appRoot, { ...live, files }) + .filter((rel) => { + // resolveFiles passes literal entries through even when they do not + // exist; a missing file is nobody's identity. + try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; } + }) + .map((rel) => prefix + rel); + } catch { + pageFiles = []; + } + if (pageFiles.length > PAGE_FILES_CAP) pageFiles = []; + + return { roots, pageFiles }; +} diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index ffffd3bd7..0ac7fe05e 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -1044,7 +1044,7 @@ describe('detectUrl — browser-only fixtures', () => { }); }); await page.evaluate(browserScript); - const scan = (scanId, disabledValues) => page.evaluate(async (config) => { + const scan = (scanId, disabledValues, extraConfig = {}) => page.evaluate(async (config) => { window.postMessage({ source: 'impeccable-command', action: 'scan', config }, '*'); const deadline = Date.now() + 2000; while ( @@ -1060,11 +1060,14 @@ describe('detectUrl — browser-only fixtures', () => { return { total: flat.length, colors: flat.filter(finding => finding.type === 'design-system-color').length, + colorValues: flat + .filter(finding => finding.type === 'design-system-color') + .map(finding => finding.ignoreValue || ''), fonts: flat .filter(finding => finding.type === 'design-system-font') .map(finding => finding.ignoreValue || ''), }; - }, { scanId, visualContrast: false, designSystem, ...(disabledValues ? { disabledValues } : {}) }); + }, { scanId, visualContrast: false, designSystem, ...(disabledValues ? { disabledValues } : {}), ...extraConfig }); const unfiltered = await scan('scan-dv-1'); assert.ok( @@ -1089,6 +1092,39 @@ describe('detectUrl — browser-only fixtures', () => { unfiltered.colors, `expected unrelated design-system findings to survive, got: ${JSON.stringify({ unfiltered, filtered })}`, ); + + // Color waivers match by value, not by spelling: the browser reports + // computed rgb(...) strings, the waiver is written as hex (mirrors + // ignoreValueMatches -> colorIgnoreKey in cli/lib/impeccable-config.mjs). + const rgbToHex = (value) => { + const m = String(value).match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/i); + if (!m) return null; + return `#${[m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0')).join('')}`; + }; + const rgbColor = unfiltered.colorValues.find(value => rgbToHex(value)); + assert.ok( + rgbColor, + `expected an rgb()-reported design-system-color finding, got: ${JSON.stringify(unfiltered.colorValues)}`, + ); + const hexWaiver = rgbToHex(rgbColor); + const colorFiltered = await scan('scan-dv-3', [{ rule: 'design-system-color', value: hexWaiver }]); + const waivedColorCount = unfiltered.colorValues.filter(value => value === rgbColor).length; + assert.ok(waivedColorCount > 0); + assert.equal( + colorFiltered.colors, + unfiltered.colors - waivedColorCount, + `expected the hex waiver ${hexWaiver} to suppress the ${rgbColor} findings, got: ${JSON.stringify({ colorValues: unfiltered.colorValues, colorFiltered })}`, + ); + assert.equal( + colorFiltered.fonts.some(value => /poppins/i.test(value)), + true, + `expected unrelated font findings to survive the color waiver, got: ${JSON.stringify(colorFiltered)}`, + ); + + // A page waived wholesale by detector.ignoreFiles arrives with + // config.skipScan and must scan to nothing at all. + const skipped = await scan('scan-dv-4', null, { skipScan: true }); + assert.equal(skipped.total, 0, `expected skipScan to empty the scan, got: ${JSON.stringify(skipped)}`); await page.close(); } finally { await browser.close().catch(() => {}); diff --git a/tests/live-browser-ignores.test.mjs b/tests/live-browser-ignores.test.mjs index 811eed6d7..46745fa3e 100644 --- a/tests/live-browser-ignores.test.mjs +++ b/tests/live-browser-ignores.test.mjs @@ -21,7 +21,7 @@ function loadIgnoresApi() { const resolve = loadIgnoresApi().resolveDetectIgnores; -const EMPTY = { disabledRules: [], disabledValues: [] }; +const EMPTY = { disabledRules: [], disabledValues: [], skipScan: false }; describe('live-browser-ignores resolver', () => { it('registers a versioned API on the root', () => { @@ -226,10 +226,106 @@ describe('live-browser-ignores resolver', () => { ); }); + it('resolves a URL to its one served file when pageFiles knows it', () => { + // PR #645 review discussion r3840011436: with src/ and public/ both + // served, /foo.html used to borrow identities from every root. The + // served page list disambiguates: this URL serves public/foo.html, so + // src-scoped waivers must not apply. + const ignores = { + roots: ['src/', 'public/'], + pageFiles: ['src/other.html', 'public/foo.html'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['src/foo.html'] }, + { rule: 'gradient-text', value: '*', files: ['public/foo.html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/foo.html' }); + assert.deepEqual(out.disabledRules, ['gradient-text']); + }); + + it('keeps ambiguity conservative when served files share the URL suffix', () => { + const ignores = { + roots: ['src/', 'public/'], + pageFiles: ['src/foo.html', 'public/foo.html'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['src/foo.html'] }, + { rule: 'gradient-text', value: '*', files: ['public/foo.html'] }, + { rule: 'em-dash-overuse', value: '*', files: ['foo.html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/foo.html' }); + // Neither root-scoped waiver can claim the page; the bare spelling + // still applies whichever root serves it. + assert.deepEqual(out.disabledRules, ['em-dash-overuse']); + }); + + it('falls back to the common ancestor when index files collide across depths', () => { + // /index.html suffix-matches both served index files; the ambiguity + // resolves through the common ancestor, which still yields the correct + // shallow identity and never the deep one. + const ignores = { + roots: ['prototype/', 'prototype/library/'], + pageFiles: ['prototype/index.html', 'prototype/library/index.html'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['prototype/index.html'] }, + { rule: 'gradient-text', value: '*', files: ['prototype/library/index.html'] }, + ], + }; + const out = resolve({ ignores, pathname: '/index.html' }); + assert.deepEqual(out.disabledRules, ['dark-glow']); + }); + + it('skips the scan on pages named by ignoreFiles', () => { + const ignores = { + roots: ['prototype/'], + ignoreFiles: ['prototype/library/**'], + ignoreRules: ['dark-glow'], + }; + const waived = resolve({ ignores, pathname: '/library/buttons.html' }); + assert.deepEqual(waived, { disabledRules: [], disabledValues: [], skipScan: true }); + const scanned = resolve({ ignores, pathname: '/index.html' }); + assert.equal(scanned.skipScan, false); + assert.deepEqual(scanned.disabledRules, ['dark-glow']); + }); + + it('matches ignoreFiles by basename like the CLI glob matcher', () => { + const out = resolve({ + ignores: { ignoreFiles: ['buttons.html'], roots: ['prototype/'] }, + pathname: '/library/buttons.html', + }); + assert.equal(out.skipScan, true); + }); + + it('treats a malformed ignoreFiles value as no waiver at all', () => { + const out = resolve({ + ignores: { ignoreFiles: 'prototype/**', roots: [] }, + pathname: '/index.html', + }); + assert.equal(out.skipScan, false); + }); + + it('drops entries scoped to source paths that no route URL can match', () => { + // Framework apps inject into source files while scans see route URLs; a + // source-scoped entry must fail conservative (finding shown), never + // suppress by accident. Pinned so a refactor cannot flip the direction. + const ignores = { + roots: ['src/'], + pageFiles: ['src/routes/about/+page.svelte'], + ignoreValues: [ + { rule: 'dark-glow', value: '*', files: ['src/routes/about/+page.svelte'] }, + ], + }; + const out = resolve({ ignores, pathname: '/about' }); + assert.deepEqual(out.disabledRules, []); + assert.equal(out.skipScan, false); + }); + it('survives malformed roots and percent-escapes without throwing', () => { const out = resolve({ ignores: { roots: 7, + pageFiles: 'not-a-list', + ignoreFiles: [null, 42], ignoreRules: ['dark-glow'], ignoreValues: [{ rule: 'gradient-text', value: '*', files: ['broken%.html'] }], }, diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index 5c984c47f..7796404e3 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -714,14 +714,19 @@ describe('live-browser.js regression guards', () => { ); assert.match( SOURCE, - /function requestDetectScan\(\)[\s\S]{0,240}?const scanId = String\(\+\+detectScanSeq\);[\s\S]{0,80}?activeDetectScanId = scanId;[\s\S]{0,1400}?config: \{ scanId, disabledRules: ignores\.disabledRules, disabledValues: ignores\.disabledValues \}/, + /function requestDetectScan\(\)[\s\S]{0,240}?const scanId = String\(\+\+detectScanSeq\);[\s\S]{0,80}?activeDetectScanId = scanId;[\s\S]{0,2200}?config: \{\s*scanId,\s*disabledRules: ignores\.disabledRules \|\| \[\],\s*disabledValues: ignores\.disabledValues \|\| \[\],\s*skipScan: ignores\.skipScan === true,\s*\},/, 'Detect scans must send a fresh scan id plus the resolved project waivers to the detector', ); assert.match( SOURCE, - /typeof ignoresApi\?\.resolveDetectIgnores === 'function'[\s\S]{0,300}?: \{ disabledRules: \[\], disabledValues: \[\] \}/, + /let ignores = \{ disabledRules: \[\], disabledValues: \[\], skipScan: false \};\s*if \(typeof ignoresApi\?\.resolveDetectIgnores === 'function'\) \{\s*try \{/, 'a cached live.js without the ignores resolver part must still scan, just unfiltered', ); + assert.match( + SOURCE, + /\} catch \(e\) \{\s*ignores = \{ disabledRules: \[\], disabledValues: \[\], skipScan: false \};\s*\}/, + 'a throwing ignores resolver must degrade to an unfiltered scan, not break the detect toggle', + ); assert.match( SOURCE, /if \(!detectActive\) return;[\s\S]{0,80}?if \(activeDetectScanId && e\.data\.scanId !== activeDetectScanId\) return;/, diff --git a/tests/live-browser-script-parts.test.mjs b/tests/live-browser-script-parts.test.mjs index 930f76fc9..9c1a7a9ab 100644 --- a/tests/live-browser-script-parts.test.mjs +++ b/tests/live-browser-script-parts.test.mjs @@ -56,7 +56,7 @@ describe('live browser script parts', () => { { name: 'project-ignores', file: 'live-browser-ignores.js', source: 'window.__IGNORES_PART__ = true;' }, { name: 'browser-ui', file: 'live-browser.js', source: 'window.__BROWSER_PART__ = true;' }, ], - projectIgnores: { ignoreRules: ['dark-glow'], ignoreValues: [], roots: ['prototype/'] }, + projectIgnores: { ignoreRules: ['dark-glow'], ignoreValues: [], ignoreFiles: [], roots: ['prototype/'], pageFiles: ['prototype/index.html'] }, }); const tokenIndex = script.indexOf('window.__IMPECCABLE_TOKEN__'); @@ -79,7 +79,7 @@ describe('live browser script parts', () => { assert.ok(sessionIndex < domIndex); assert.ok(domIndex < ignoresIndex); assert.ok(ignoresIndex < browserIndex); - assert.match(script, /window\.__IMPECCABLE_PROJECT_IGNORES__ = \{"ignoreRules":\["dark-glow"\],"ignoreValues":\[\],"roots":\["prototype\/"\]\};/); + assert.match(script, /window\.__IMPECCABLE_PROJECT_IGNORES__ = \{"ignoreRules":\["dark-glow"\],"ignoreValues":\[\],"ignoreFiles":\[\],"roots":\["prototype\/"\],"pageFiles":\["prototype\/index\.html"\]\};/); assert.match(script, /impeccable live script part: session-state \(live-browser-session\.js\)/); assert.match(script, /impeccable live script part: dom-helpers \(live-browser-dom\.js\)/); assert.match(script, /impeccable live script part: project-ignores \(live-browser-ignores\.js\)/); diff --git a/tests/live-project-ignores.test.mjs b/tests/live-project-ignores.test.mjs new file mode 100644 index 000000000..3669d63f2 --- /dev/null +++ b/tests/live-project-ignores.test.mjs @@ -0,0 +1,145 @@ +import { describe, it, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { collectProjectDetectorIgnores } from '../skill/scripts/live/project-ignores.mjs'; + +const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const SCRIPTS_DIR = path.join(REPO_ROOT, 'skill', 'scripts'); + +const tempDirs = []; +function makeTemp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-project-ignores-')); + tempDirs.push(dir); + return dir; +} +after(() => { + for (const dir of tempDirs) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } + } +}); + +function write(root, rel, content) { + const filePath = path.join(root, rel); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function writeDetectorConfig(root, detector) { + write(root, '.impeccable/config.json', JSON.stringify({ detector }, null, 2)); +} + +function writeLiveConfig(root, files) { + write(root, '.impeccable/live/config.json', JSON.stringify({ + files, + insertBefore: '', + commentSyntax: 'html', + }, null, 2)); +} + +describe('collectProjectDetectorIgnores', () => { + it('collects waivers, roots, and pageFiles from a single-root project', () => { + const app = makeTemp(); + write(app, 'package.json', '{"name":"single","private":true}\n'); + writeDetectorConfig(app, { + ignoreRules: ['ai-color-palette'], + ignoreFiles: ['prototype/legacy/**'], + ignoreValues: [ + { rule: 'gradient-text', value: '*', files: ['prototype/library/**'], reason: 'stays local' }, + ], + }); + writeLiveConfig(app, ['prototype/index.html', 'prototype/library/buttons.html']); + write(app, 'prototype/index.html', ''); + write(app, 'prototype/library/buttons.html', ''); + + const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR }); + assert.deepEqual(out.ignoreRules, ['ai-color-palette']); + assert.deepEqual(out.ignoreFiles, ['prototype/legacy/**']); + // createdAt/reason stay local; only rule/value/files ride to the browser. + assert.deepEqual(out.ignoreValues, [ + { rule: 'gradient-text', value: '*', files: ['prototype/library/**'] }, + ]); + assert.deepEqual(out.roots.sort(), ['prototype/', 'prototype/library/']); + assert.deepEqual(out.pageFiles.sort(), ['prototype/index.html', 'prototype/library/buttons.html']); + }); + + it('reads waivers keyed at the repo root, where the hook and the CLI put them', () => { + // The monorepo shape from the PR #645 review: the live server chdirs + // onto the child appRoot, while resolveCacheCwd keys the hook's config + // at the session cwd, which is the repo root. + const repo = makeTemp(); + const app = path.join(repo, 'site'); + fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); + write(app, 'package.json', '{"name":"site","private":true}\n'); + writeDetectorConfig(repo, { + ignoreRules: ['ai-color-palette'], + ignoreValues: [{ rule: 'overused-font', value: 'space grotesk' }], + }); + writeLiveConfig(app, ['prototype/index.html']); + write(app, 'prototype/index.html', ''); + + const out = collectProjectDetectorIgnores({ appRoot: app, repoRoot: repo, scriptsDir: SCRIPTS_DIR }); + assert.deepEqual(out.ignoreRules, ['ai-color-palette']); + assert.deepEqual(out.ignoreValues, [{ rule: 'overused-font', value: 'space grotesk' }]); + // Identities serialize repo-relative so waivers spelled from either root + // match through the resolver's suffix expansion. + assert.deepEqual(out.roots, ['site/prototype/']); + assert.deepEqual(out.pageFiles, ['site/prototype/index.html']); + }); + + it('unions configs across roots and dedupes identical value entries', () => { + const repo = makeTemp(); + const app = path.join(repo, 'site'); + fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); + write(app, 'package.json', '{"name":"site","private":true}\n'); + writeDetectorConfig(repo, { + ignoreRules: ['ai-color-palette'], + ignoreValues: [{ rule: 'overused-font', value: 'space grotesk' }], + }); + writeDetectorConfig(app, { + ignoreRules: ['gradient-text', 'ai-color-palette'], + ignoreValues: [{ rule: 'overused-font', value: 'space grotesk' }], + }); + writeLiveConfig(app, ['prototype/index.html']); + write(app, 'prototype/index.html', ''); + + const out = collectProjectDetectorIgnores({ appRoot: app, repoRoot: repo, scriptsDir: SCRIPTS_DIR }); + assert.deepEqual(out.ignoreRules.sort(), ['ai-color-palette', 'gradient-text']); + assert.deepEqual(out.ignoreValues, [{ rule: 'overused-font', value: 'space grotesk' }]); + }); + + it('expands glob file entries to existing files and drops missing literals', () => { + const app = makeTemp(); + write(app, 'package.json', '{"name":"globs","private":true}\n'); + writeLiveConfig(app, ['prototype/**/*.html', 'prototype/not-created-yet.html']); + write(app, 'prototype/index.html', ''); + write(app, 'prototype/library/buttons.html', ''); + + const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR }); + assert.deepEqual(out.pageFiles.sort(), ['prototype/index.html', 'prototype/library/buttons.html']); + assert.deepEqual(out.roots.sort(), ['prototype/']); + }); + + it('degrades to empty arrays when nothing is configured', () => { + const app = makeTemp(); + write(app, 'package.json', '{"name":"bare","private":true}\n'); + const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR }); + assert.deepEqual(out, { ignoreRules: [], ignoreValues: [], ignoreFiles: [], roots: [], pageFiles: [] }); + }); + + it('survives a malformed detector config without throwing', () => { + const app = makeTemp(); + write(app, 'package.json', '{"name":"broken","private":true}\n'); + write(app, '.impeccable/config.json', '{"detector":{"ignoreRules":"foo","ignoreValues":[null,7],"ignoreFiles":{}}}'); + writeLiveConfig(app, ['prototype/index.html']); + write(app, 'prototype/index.html', ''); + + const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR }); + assert.deepEqual(out.ignoreRules, []); + assert.deepEqual(out.ignoreValues, []); + assert.deepEqual(out.ignoreFiles, []); + assert.deepEqual(out.pageFiles, ['prototype/index.html']); + }); +}); From 46f13989eb2ed89e312e8448315875715d7ec1c1 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Fri, 28 Aug 2026 09:38:40 +0500 Subject: [PATCH 14/16] Fix: write the build-path flip before answering the POST serve-question answered POST /build-path with 200 and only then wrote the flip file. The caller is a separate process, so the response could reach it while the server was still preempted before the write landed: a poller that trusted the 200 could look for the flip file and miss it. Measured on a loaded machine, the old order lost that race 29 times out of 40; writing first and answering after loses it 0 times out of 40. This is what made tests/serve-question.test.mjs fail intermittently in CI on the Node 22 job while passing on Node 24. AI-assisted change: diagnosed and implemented with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 --- skill/scripts/serve-question.mjs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/skill/scripts/serve-question.mjs b/skill/scripts/serve-question.mjs index e08052c48..689c9559a 100644 --- a/skill/scripts/serve-question.mjs +++ b/skill/scripts/serve-question.mjs @@ -1667,19 +1667,24 @@ const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end('{"ok":true}'); let value = null; try { value = JSON.parse(body).value; } catch { /* ignore */ } - if (value !== 'comp' && value !== 'code') return; - const wasComp = liveBuildPath === 'comp'; - liveBuildPath = value; - // Only a flip TO comp needs the agent mid-round: comps must start - // rendering into the declared slots. The reverse is free. - if (detachedKey && value === 'comp' && !wasComp) { - fs.mkdirSync(QUESTION_DIR, { recursive: true }); - fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + if (value === 'comp' || value === 'code') { + const wasComp = liveBuildPath === 'comp'; + liveBuildPath = value; + // Only a flip TO comp needs the agent mid-round: comps must start + // rendering into the declared slots. The reverse is free. + if (detachedKey && value === 'comp' && !wasComp) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n'); + } } + // Answer only once the flip is on disk. Responding first raced the + // caller: the 200 reached the client (a separate process) while this + // one could still be preempted before the write landed, so a poller + // that trusted the 200 could look for the flip file and miss it. + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); }); return; } From 00095adb26478be836f39ff6553d86341e8ebd0a Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Fri, 28 Aug 2026 12:20:02 +0500 Subject: [PATCH 15/16] Fix: skipScan must cover the visual contrast stage too Bugbot on PR #665: the skipScan guard emptied only the analytic collectBrowserFindings pass, and scan()'s detached visual-contrast stage then repopulated an ignoreFiles-waived page with contrast markers and a second non-zero results post. Hoist the guard into skipScanActive() and honor it in scan() and the async collector; regenerate the browser bundle. Adds a browser-backed regression test that reproduces the leak (second results post carrying low-contrast findings) and pins the zero contract; drops a tautological assert flagged in review. AI-assisted change: implemented with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 --- cli/engine/browser/injected/index.mjs | 24 +++++-- cli/engine/detect-antipatterns-browser.js | 24 +++++-- tests/detect-antipatterns-browser.test.mjs | 79 +++++++++++++++++++++- 3 files changed, 112 insertions(+), 15 deletions(-) diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index 0d0c26a16..febf7f297 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1472,13 +1472,17 @@ 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() { - // A page matched by detector.ignoreFiles is waived wholesale: answer the - // scan with the empty shape 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. - if (EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true) { + if (skipScanActive()) { return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; } const groupMap = new Map(); @@ -2013,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, @@ -2066,7 +2076,7 @@ if (IS_BROWSER) { const generation = scanGeneration; const collected = collectBrowserFindings(); const allFindings = renderBrowserFindings(collected, options); - if (shouldRunVisualContrast(options)) { + if (!skipScanActive() && shouldRunVisualContrast(options)) { addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) .then(() => { if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 281e49810..7df671eb5 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -8131,13 +8131,17 @@ 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() { - // A page matched by detector.ignoreFiles is waived wholesale: answer the - // scan with the empty shape 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. - if (EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true) { + if (skipScanActive()) { return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] }; } const groupMap = new Map(); @@ -8672,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, @@ -8725,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/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index 0ac7fe05e..9a976fa2a 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -1109,7 +1109,6 @@ describe('detectUrl — browser-only fixtures', () => { const hexWaiver = rgbToHex(rgbColor); const colorFiltered = await scan('scan-dv-3', [{ rule: 'design-system-color', value: hexWaiver }]); const waivedColorCount = unfiltered.colorValues.filter(value => value === rgbColor).length; - assert.ok(waivedColorCount > 0); assert.equal( colorFiltered.colors, unfiltered.colors - waivedColorCount, @@ -1131,6 +1130,84 @@ describe('detectUrl — browser-only fixtures', () => { } }); + it('extension scan: skipScan suppresses the visual contrast stage too', async () => { + const puppeteer = await import('puppeteer'); + const browser = await puppeteer.default.launch({ + headless: true, + args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [], + }); + try { + const page = await browser.newPage(); + // Keep failing visual-contrast cards inside the no-scroll viewport. + await page.setViewport({ width: 1280, height: 1000 }); + await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' }); + const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8'); + await page.evaluate(() => { + document.documentElement.dataset.impeccableExtension = 'true'; + window.__impeccableMessages = []; + window.addEventListener('message', event => { + if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return; + window.__impeccableMessages.push(event.data); + }); + }); + await page.evaluate(browserScript); + const resultsFor = (scanId) => page.evaluate((id) => ( + (window.__impeccableMessages || []) + .filter(m => m.source === 'impeccable-results' && m.scanId === id) + .map(m => ({ + count: m.count, + types: (m.findings || []).flatMap(g => (g.findings || []).map(f => f.type || f.id)), + })) + ), scanId); + + // Control: the visual pass runs after the analytic scan and re-posts + // results carrying its low-contrast findings. This is exactly what an + // ignoreFiles-waived page must not do. + await page.evaluate(() => { + window.postMessage({ + source: 'impeccable-command', + action: 'scan', + config: { scanId: 'vc-skip-1', visualContrast: true, visualContrastMaxCandidates: 20 }, + }, '*'); + }); + const controlDeadline = Date.now() + 8000; + let control = []; + while (Date.now() < controlDeadline) { + control = await resultsFor('vc-skip-1'); + if (control.some(r => r.types.includes('low-contrast'))) break; + await new Promise(resolve => setTimeout(resolve, 100)); + } + assert.ok( + control.some(r => r.types.includes('low-contrast')), + `expected the control scan's visual pass to report low-contrast, got: ${JSON.stringify(control)}`, + ); + + // skipScan: a page waived wholesale by detector.ignoreFiles must stay + // at zero through the async visual stage as well: no results post with + // findings, no markers. + await page.evaluate(() => { + window.postMessage({ + source: 'impeccable-command', + action: 'scan', + config: { scanId: 'vc-skip-2', visualContrast: true, visualContrastMaxCandidates: 20, skipScan: true }, + }, '*'); + }); + await new Promise(resolve => setTimeout(resolve, 2500)); + const skipped = await resultsFor('vc-skip-2'); + assert.ok(skipped.length >= 1, `expected the skipScan scan to post results, got: ${JSON.stringify(skipped)}`); + assert.ok( + skipped.every(r => r.count === 0 && r.types.length === 0), + `expected every skipScan results post to stay empty, got: ${JSON.stringify(skipped)}`, + ); + const overlays = await page.evaluate(() => + document.querySelectorAll('.impeccable-overlay, .impeccable-label').length); + assert.equal(overlays, 0, `expected no markers on a skipScan page, got ${overlays}`); + await page.close(); + } finally { + await browser.close().catch(() => {}); + } + }); + it('browser API: impeccableDetect is pure, impeccableScan decorates', async () => { const puppeteer = await import('puppeteer'); const browser = await puppeteer.default.launch({ From ea360025b5806fe76465c9fd865085b76405786f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:39:10 +0000 Subject: [PATCH 16/16] Sync generated provider output --- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .pi/skills/impeccable/scripts/live-browser.js | 32 ++-- .pi/skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- .../detector/browser/injected/index.mjs | 82 ++++++++++- .../detector/detect-antipatterns-browser.js | 82 ++++++++++- .../scripts/live-browser-ignores.js | 65 ++++++-- .../skills/impeccable/scripts/live-browser.js | 32 ++-- .../skills/impeccable/scripts/live-server.mjs | 60 ++------ .../scripts/live/project-ignores.mjs | 139 ++++++++++++++++++ .../impeccable/scripts/serve-question.mjs | 25 ++-- 112 files changed, 6240 insertions(+), 1520 deletions(-) create mode 100644 .agents/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .claude/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .github/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .grok/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .hermes/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .pi/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .trae/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/project-ignores.mjs create mode 100644 plugin/skills/impeccable/scripts/live/project-ignores.mjs 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; }