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 <noreply@anthropic.com>
This commit is contained in:
Guitaraholic
2026-08-28 07:13:43 +05:00
committed by Abdul Wahab
co-authored by Claude Fable 5
parent f86473ba7d
commit 5330fa358e
11 changed files with 726 additions and 9 deletions
+181
View File
@@ -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);
+15 -1
View File
@@ -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 },
}, '*');
}
+50
View File
@@ -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',
+8 -1
View File
@@ -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 || '');