mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Read waiver config from every live root (appRoot, contextRoot, repoRoot), so monorepo projects whose config lives at the repo root reach the overlay; serialize served roots and page identities repo-relative there. Resolve each page URL to its actual serving file via the inject config's resolved page list before applying file-scoped waivers; ambiguous URLs keep the conservative common-ancestor fallback (PR #645 review discussion r3840011436). Honour detector.ignoreFiles: a wholly waived page now scans to zero findings in the overlay, matching the CLI and the edit hook. Guard the resolver call so a throwing resolver degrades to an unfiltered scan instead of breaking the detect toggle. Match design-system-color waivers by color value across hex and rgb() spellings, and stop extracting font values for bounce-easing findings, mirroring extractFindingIgnoreValue. Regenerate the browser bundle. AI-assisted change: reviewed, planned, and implemented with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
243 lines
10 KiB
JavaScript
243 lines
10 KiB
JavaScript
/**
|
|
* Browser-side resolution of project detector waivers for Impeccable live mode.
|
|
*
|
|
* The live server serializes `.impeccable/config.json` + `config.local.json`
|
|
* detector ignores (plus the served-root prefixes from the inject config's
|
|
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
|
|
* resolves that config against the current page's URL path when a detect scan
|
|
* starts, so the overlay suppresses the same findings the CLI and the edit
|
|
* hook do (issue #639).
|
|
*
|
|
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
|
|
* 1. `ignoreRules` suppress a rule project-wide.
|
|
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
|
|
* files their globs name. The CLI never applies an unscoped wildcard
|
|
* (isIgnoredFindingValue returns false for it), so neither does this.
|
|
* 3. Remaining `ignoreValues` entries match on the finding's own value;
|
|
* those are forwarded as `disabledValues` for the detector bundle to
|
|
* apply where the findings are assembled.
|
|
* 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
|
|
* overlay UI bundle.
|
|
*/
|
|
(function (root) {
|
|
'use strict';
|
|
if (!root) return;
|
|
|
|
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
|
|
// cli/lib/impeccable-config.mjs.
|
|
function normalizeIgnoreRule(rule) {
|
|
return String(rule || '').trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeIgnoreValue(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.replace(/^["']|["']$/g, '')
|
|
.replace(/\+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.toLowerCase();
|
|
}
|
|
|
|
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
|
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
|
|
function globToRegex(glob) {
|
|
let re = '^';
|
|
let i = 0;
|
|
while (i < glob.length) {
|
|
const c = glob[i];
|
|
if (c === '*') {
|
|
if (glob[i + 1] === '*') {
|
|
re += '.*';
|
|
i += 2;
|
|
if (glob[i] === '/') i += 1;
|
|
} else {
|
|
re += '[^/]*';
|
|
i += 1;
|
|
}
|
|
} else if (c === '?') {
|
|
re += '[^/]';
|
|
i += 1;
|
|
} else if (c === '{') {
|
|
const end = glob.indexOf('}', i);
|
|
if (end === -1) { re += '\\{'; i += 1; continue; }
|
|
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
|
re += `(?:${parts.join('|')})`;
|
|
i = end + 1;
|
|
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
|
re += `\\${c}`;
|
|
i += 1;
|
|
} else {
|
|
re += c;
|
|
i += 1;
|
|
}
|
|
}
|
|
re += '$';
|
|
return new RegExp(re);
|
|
}
|
|
|
|
// The project-relative paths this page could be known as. Ignore globs are
|
|
// project-relative (prototype/foo.html) and the URL is site-relative
|
|
// (/foo.html), because a static server's root usually sits inside the
|
|
// project; `roots` carries that prefix. The server reads it from the inject
|
|
// config's own `files` globs, which already state where the served pages
|
|
// are. Do not derive it from the ignore globs: a single entry scoped to
|
|
// prototype/library/** would then lend prototype/library/ as a candidate
|
|
// prefix to every page, and that rule would suppress site-wide.
|
|
//
|
|
// Each prefixed path also contributes its slash suffixes, mirroring
|
|
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
|
|
// matches globs against every path suffix of the finding's file).
|
|
//
|
|
// One live session is served by one server, so a single document root must
|
|
// sit at or above every configured page. The only prefix that can safely
|
|
// be asserted is therefore the deepest common ancestor of the glob roots.
|
|
// Treating each glob's own prefix as an identity goes wrong in both
|
|
// directions: disjoint roots (src/ and public/) invent simultaneous
|
|
// identities for one URL, so a waiver scoped to src/foo.html hides a
|
|
// finding on a page served from public/foo.html; nested roots (prototype/
|
|
// and prototype/library/, from globs at two depths in one tree) are not
|
|
// alternatives at all, and demanding a waiver match under both stops
|
|
// prototype/index.html from applying anywhere. When the globs share no
|
|
// common root, no prefix is asserted and only the URL path itself matches.
|
|
function pageCandidates(pathname, roots, pageFiles) {
|
|
let pagePath = String(pathname || '');
|
|
try {
|
|
pagePath = decodeURIComponent(pagePath);
|
|
} catch {
|
|
// Malformed percent-escape: match on the raw path rather than throwing.
|
|
}
|
|
pagePath = pagePath.replace(/^\/+/, '');
|
|
// A directory URL serves that directory's index, and the ignore globs
|
|
// name files. Without this, /news/ never matches prototype/news/index.html.
|
|
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
|
|
|
|
const 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;
|
|
prefixes.push(entry.split('/').filter(Boolean));
|
|
}
|
|
let common = prefixes.length > 0 ? prefixes[0] : [];
|
|
for (const segments of prefixes.slice(1)) {
|
|
let i = 0;
|
|
while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1;
|
|
common = common.slice(0, i);
|
|
}
|
|
|
|
if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath);
|
|
return [...candidates];
|
|
}
|
|
|
|
function matchesScope(globs, candidates) {
|
|
return globs.some((glob) => {
|
|
let re;
|
|
try {
|
|
re = globToRegex(String(glob));
|
|
} catch {
|
|
// Malformed glob: skip it, as matchesAnyGlob does in the CLI.
|
|
return false;
|
|
}
|
|
return candidates.some((candidate) => re.test(candidate));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve the serialized project ignores for one page.
|
|
*
|
|
* @param {object} options
|
|
* @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__,
|
|
* in whatever state it arrived: absent, null, or hand-edited into the
|
|
* wrong shape. Every read tolerates that and degrades to no filtering.
|
|
* @param {string} options.pathname location.pathname of the scanned page.
|
|
* @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}>, 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, 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)
|
|
.filter((rule) => typeof rule === 'string')
|
|
.map(normalizeIgnoreRule)
|
|
.filter(Boolean),
|
|
);
|
|
const disabledValues = [];
|
|
|
|
for (const entry of asArray(config.ignoreValues)) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const rule = normalizeIgnoreRule(entry.rule);
|
|
const value = normalizeIgnoreValue(entry.value);
|
|
if (!rule || !value) continue;
|
|
const files = [
|
|
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
|
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
|
|
];
|
|
if (value === '*') {
|
|
// Wildcards suppress their rule only inside the files they name.
|
|
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
|
|
continue;
|
|
}
|
|
if (files.length > 0 && !matchesScope(files, candidates)) continue;
|
|
disabledValues.push({ rule, value });
|
|
}
|
|
|
|
return { disabledRules: [...disabledRules], disabledValues, skipScan: false };
|
|
}
|
|
|
|
root.__IMPECCABLE_LIVE_IGNORES__ = {
|
|
version: 1,
|
|
resolveDetectIgnores,
|
|
};
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|