Files
pbakaus_impeccable/skill/scripts/live/project-ignores.mjs
T
Abdul WahabandClaude Fable 5 bd9f9fe925 Fix: harden live overlay detector waivers (#639 follow-up)
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>
2026-08-28 09:08:42 +05:00

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 };
}