mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +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>
140 lines
6.3 KiB
JavaScript
140 lines
6.3 KiB
JavaScript
/**
|
|
* 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 };
|
|
}
|