Files
pbakaus_impeccable/skill/scripts/live-browser-ignores.js
T
5330fa358e 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>
2026-08-28 07:13:43 +05:00

182 lines
6.7 KiB
JavaScript

/**
* 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);