Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-08-28 13:39:10 +00:00
parent 00095adb26
commit ea360025b5
112 changed files with 6240 additions and 1520 deletions
@@ -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);
@@ -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);
@@ -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__ = {
@@ -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,
},
}, '*');
}
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
@@ -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,
},
}, '*');
}
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
@@ -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,
},
}, '*');
}
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
@@ -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,
},
}, '*');
}
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
@@ -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,
},
}, '*');
}
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
+22 -10
View File
@@ -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,
},
}, '*');
}
+11 -49
View File
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
@@ -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,
},
}, '*');
}
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
+22 -10
View File
@@ -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,
},
}, '*');
}
+11 -49
View File
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
@@ -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,
},
}, '*');
}
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
+22 -10
View File
@@ -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,
},
}, '*');
}
+11 -49
View File
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
@@ -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,
},
}, '*');
}
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
@@ -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,
},
}, '*');
}
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
@@ -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,
},
}, '*');
}
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);
@@ -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__ = {
+22 -10
View File
@@ -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,
},
}, '*');
}
+11 -49
View File
@@ -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',
@@ -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 };
}
@@ -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;
}
@@ -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);
@@ -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);

Some files were not shown because too many files have changed in this diff Show More